Skip to content

Commit c385583

Browse files
committedFeb 17, 2025
IPFS and farcaster
1 parent 95c421d commit c385583

11 files changed

+423
-205
lines changed
 

‎app/Config.hs

+9-25
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@
44

55
module Config
66
( Config (..),
7-
XConfig (..),
87
FarcasterConfig (..),
98
IPFSConfig (..),
109
loadConfig,
1110
deserializeInterval,
1211
)
1312
where
1413

15-
import Data.Aeson
14+
import Data.Aeson (FromJSON, decodeFileStrict)
1615
import qualified Data.Text as T
1716
import GHC.Generics (Generic)
1817
import System.Environment (lookupEnv)
@@ -22,27 +21,20 @@ import Text.Read (readMaybe)
2221
data Config = Config
2322
{ ipfsConfig :: IPFSConfig,
2423
farcasterConfig :: FarcasterConfig,
25-
xConfig :: XConfig,
2624
intervalMinutes :: T.Text
2725
}
2826
deriving (Show, Generic)
2927

3028
data IPFSConfig = IPFSConfig
3129
{ gateway :: T.Text,
32-
folderCID :: T.Text
30+
folderCID :: T.Text,
31+
pinataToken :: T.Text
3332
}
3433
deriving (Show, Generic)
3534

36-
newtype FarcasterConfig = FarcasterConfig
37-
{ authToken :: T.Text
38-
}
39-
deriving (Show, Generic)
40-
41-
data XConfig = XConfig
42-
{ apiKey :: T.Text,
43-
apiSecret :: T.Text,
44-
accessToken :: T.Text,
45-
accessSecret :: T.Text
35+
data FarcasterConfig = FarcasterConfig
36+
{ neynarApiKey :: T.Text,
37+
neynarUuid :: T.Text
4638
}
4739
deriving (Show, Generic)
4840

@@ -52,8 +44,6 @@ instance FromJSON IPFSConfig
5244

5345
instance FromJSON FarcasterConfig
5446

55-
instance FromJSON XConfig
56-
5747
resolveEnvVar :: T.Text -> IO T.Text
5848
resolveEnvVar t = case T.stripPrefix "${" t >>= T.stripSuffix "}" of
5949
Just var -> do
@@ -83,24 +73,18 @@ loadConfig path = do
8373
IPFSConfig
8474
<$> resolveEnvVar (gateway $ ipfsConfig rawConfig)
8575
<*> resolveEnvVar (folderCID $ ipfsConfig rawConfig)
76+
<*> resolveEnvVar (pinataToken $ ipfsConfig rawConfig)
8677

8778
resolvedFarcasterConf <-
8879
FarcasterConfig
89-
<$> resolveEnvVar (authToken $ farcasterConfig rawConfig)
90-
91-
resolvedXConf <-
92-
XConfig
93-
<$> resolveEnvVar (apiKey $ xConfig rawConfig)
94-
<*> resolveEnvVar (apiSecret $ xConfig rawConfig)
95-
<*> resolveEnvVar (accessToken $ xConfig rawConfig)
96-
<*> resolveEnvVar (accessSecret $ xConfig rawConfig)
80+
<$> resolveEnvVar (neynarApiKey $ farcasterConfig rawConfig)
81+
<*> resolveEnvVar (neynarUuid $ farcasterConfig rawConfig)
9782

9883
resolvedInterval <- resolveEnvVar (intervalMinutes rawConfig)
9984

10085
return $
10186
rawConfig
10287
{ ipfsConfig = resolvedIpfsConf,
10388
farcasterConfig = resolvedFarcasterConf,
104-
xConfig = resolvedXConf,
10589
intervalMinutes = resolvedInterval
10690
}

‎app/Farcaster.hs

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{-# LANGUAGE OverloadedStrings #-}
2+
3+
module Farcaster (postCastWithImage) where
4+
5+
import Config (FarcasterConfig (..))
6+
import Control.Exception (SomeException, try)
7+
import Data.Aeson (Value, encode, object, (.=))
8+
import qualified Data.ByteString.Lazy as BL
9+
import qualified Data.Text as T
10+
import qualified Data.Text.Encoding as TE
11+
import Network.HTTP.Client (responseBody)
12+
import Network.HTTP.Simple (Response, getResponseStatusCode, httpLBS, parseRequest, setRequestBodyLBS, setRequestHeader)
13+
14+
postCastWithImage :: FarcasterConfig -> T.Text -> T.Text -> IO Bool
15+
postCastWithImage cfg castText imgUrl = do
16+
let apiKey = neynarApiKey cfg
17+
let signerUuid = neynarUuid cfg
18+
19+
let embedObject = object ["url" .= imgUrl] :: Value
20+
let requestBody =
21+
encode $
22+
object
23+
[ "text" .= castText,
24+
"signer_uuid" .= signerUuid,
25+
"embeds" .= [embedObject]
26+
]
27+
28+
initReq <- parseRequest "POST https://api.neynar.com/v2/farcaster/cast"
29+
let request =
30+
setRequestBodyLBS requestBody $
31+
setRequestHeader "api_key" [TE.encodeUtf8 apiKey] $
32+
setRequestHeader "Content-Type" ["application/json"] initReq
33+
34+
result <- try (httpLBS request) :: IO (Either SomeException (Response BL.ByteString))
35+
36+
case result of
37+
Right response -> do
38+
putStrLn $ "response body: " ++ show (responseBody response)
39+
let status = getResponseStatusCode response
40+
if status >= 200 && status < 300
41+
then return True
42+
else do
43+
putStrLn $ "failed to post cast. status code: " ++ show status
44+
return False
45+
Left err -> do
46+
putStrLn $ "error sending request: " ++ show err
47+
return False

‎app/IPFS.hs

+51-47
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,73 @@
11
module IPFS
2-
( IPFSFile (..),
3-
listIPFSDirectory,
2+
( DirectoryEntry (..),
3+
listPinataDirectory,
44
makeIPFSUrl,
5-
isJPG,
65
)
76
where
87

98
import Config (IPFSConfig (..))
10-
import Control.Exception (try)
11-
import Data.Aeson
12-
import qualified Data.ByteString.Lazy as LBS
9+
import Data.Aeson (FromJSON, eitherDecode, parseJSON, withObject, (.:))
10+
import qualified Data.ByteString.Char8 as BC
1311
import qualified Data.Text as T
1412
import GHC.Generics (Generic)
15-
import Network.HTTP.Simple
13+
import Network.HTTP.Simple (getResponseBody, httpLBS, parseRequest_, setRequestHeader, setRequestMethod)
1614

17-
-- file representation matching IPFS directory listing
18-
data IPFSFile = IPFSFile
19-
{ name :: T.Text, -- file name with path
20-
hash :: T.Text, -- file's CID
21-
size :: Integer,
22-
type_ :: T.Text -- "File" or "Directory"
15+
data DirectoryEntry = DirectoryEntry
16+
{ name :: T.Text,
17+
hash :: T.Text,
18+
tsize :: Integer
2319
}
2420
deriving (Show, Generic)
2521

26-
instance FromJSON IPFSFile where
27-
parseJSON = withObject "IPFSFile" $ \v ->
28-
IPFSFile
29-
<$> v .: "name"
30-
<*> v .: "hash"
31-
<*> v .: "size"
32-
<*> v .: "type"
22+
newtype IPFSHash = IPFSHash
23+
{ hashValue :: T.Text
24+
}
25+
deriving (Show, Generic)
3326

34-
-- directory listing from IPFS gateway
35-
newtype DirectoryListing = DirectoryListing
36-
{ entries :: [IPFSFile]
27+
newtype IPFSDirectory = IPFSDirectory
28+
{ links :: [DirectoryEntry]
3729
}
3830
deriving (Show, Generic)
3931

40-
instance FromJSON DirectoryListing
32+
instance FromJSON IPFSDirectory where
33+
parseJSON = withObject "IPFSDirectory" $ \v ->
34+
IPFSDirectory
35+
<$> v .: "Links"
36+
37+
instance FromJSON IPFSHash where
38+
parseJSON = withObject "IPFSHash" $ \v ->
39+
IPFSHash
40+
<$> v .: "/"
41+
42+
instance FromJSON DirectoryEntry where
43+
parseJSON = withObject "DirectoryEntry" $ \v -> do
44+
_name <- v .: "Name"
45+
hashObj <- v .: "Hash"
46+
_hashValue <- hashObj .: "/"
47+
_tsize <- v .: "Tsize"
48+
return $ DirectoryEntry _name _hashValue _tsize
4149

42-
makeIPFSUrl :: IPFSConfig -> T.Text -> T.Text
43-
makeIPFSUrl cfg path =
50+
-- for accessing content - uses the configured gateway
51+
makeIPFSUrl :: IPFSConfig -> DirectoryEntry -> T.Text
52+
makeIPFSUrl cfg entry =
4453
gateway cfg
4554
<> "/ipfs/"
4655
<> folderCID cfg
47-
<> (if T.null path then "" else "/" <> path)
56+
<> "/"
57+
<> name entry
4858

49-
isJPG :: T.Text -> Bool
50-
isJPG filename =
51-
any
52-
(`T.isSuffixOf` T.toLower filename)
53-
[".jpg", ".jpeg"]
59+
-- for listing directory contents - uses Pinata gateway
60+
listPinataDirectory :: IPFSConfig -> IO (Either String [DirectoryEntry])
61+
listPinataDirectory cfg = do
62+
let url = "https://gateway.pinata.cloud/ipfs/" <> T.unpack (folderCID cfg) <> "?format=dag-json"
63+
request =
64+
setRequestMethod "GET" $
65+
setRequestHeader "Authorization" [BC.pack $ "Bearer " <> T.unpack (pinataToken cfg)] $
66+
setRequestHeader "Accept" ["application/json"] $
67+
parseRequest_ url
5468

55-
listIPFSDirectory :: IPFSConfig -> IO [IPFSFile]
56-
listIPFSDirectory cfg = do
57-
let url = gateway cfg <> "/ipfs/" <> folderCID cfg <> "?format=json"
58-
request <- parseRequest (T.unpack url)
59-
response <- try (httpLBS request) :: IO (Either HttpException (Response LBS.ByteString))
60-
case response of
61-
Right res -> do
62-
case eitherDecode (getResponseBody res) of
63-
Right listing -> return $ filter (\f -> type_ f == "File") $ entries listing
64-
Left err -> do
65-
putStrLn $ "error parsing directory listing: " ++ err
66-
return []
67-
Left err -> do
68-
putStrLn $ "error fetching directory listing: " ++ show err
69-
return []
69+
response <- httpLBS request
70+
let result = eitherDecode $ getResponseBody response
71+
pure $ case result of
72+
Left err -> Left err
73+
Right obj -> Right (links obj)

‎app/Main.hs

+36-33
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import Control.Concurrent (threadDelay)
55
import Control.Exception (try)
66
import Control.Monad (forever)
77
import qualified Data.ByteString.Lazy as BL
8+
import Data.Char (isAlphaNum)
89
import qualified Data.Text as T
10+
import Farcaster
911
import IPFS
10-
import Network.HTTP.Simple
11-
import Social
12+
import Network.HTTP.Simple (HttpException, Response, getResponseBody, httpLBS, parseRequest)
1213
import System.Directory (createDirectoryIfMissing, removeFile)
1314
import System.FilePath ((</>))
1415
import System.Random (randomRIO)
@@ -25,28 +26,30 @@ downloadImage url path = do
2526
putStrLn $ "error downloading image: " ++ show err
2627
return False
2728

28-
processFile :: Config -> IPFSFile -> IO ()
29-
processFile cfg file = do
30-
let ipfsUrl = makeIPFSUrl (ipfsConfig cfg) (name file)
31-
putStrLn $ "processing: " ++ T.unpack (name file)
32-
let tempPath = "temp" </> T.unpack (name file)
33-
createDirectoryIfMissing True "temp"
29+
isValidImage :: T.Text -> Bool
30+
isValidImage fileName =
31+
let lowercase = T.toLower fileName
32+
in T.isSuffixOf ".jpg" lowercase
33+
&& not (T.isPrefixOf "." lowercase) -- excludes .DS_Store and other hidden files
34+
&& not (T.isPrefixOf "_" lowercase) -- excludes _thumb files etc
35+
&& T.all isAllowedChar fileName -- ensures filename only contains safe characters
36+
where
37+
isAllowedChar c = isAlphaNum c || c `elem` ['.', '-', '_']
3438

39+
processFile :: Config -> DirectoryEntry -> IO ()
40+
processFile cfg entry = do
41+
let ipfsUrl = makeIPFSUrl (ipfsConfig cfg) entry
42+
putStrLn $ "processing: " ++ T.unpack (name entry)
43+
let tempPath = "temp" </> T.unpack (name entry)
44+
createDirectoryIfMissing True "temp"
3545
downloadSuccess <- downloadImage ipfsUrl tempPath
3646
if downloadSuccess
3747
then do
3848
let postText = "A bit of Saturn"
3949

40-
farcasterSuccess <- postToFarcaster (farcasterConfig cfg) postText ipfsUrl
50+
farcasterSuccess <- postCastWithImage (farcasterConfig cfg) postText ipfsUrl
4151
putStrLn $ "farcaster post: " ++ if farcasterSuccess then "success" else "failed"
4252

43-
mediaIdMaybe <- uploadToX (xConfig cfg) tempPath
44-
case mediaIdMaybe of
45-
Just mediaId -> do
46-
xSuccess <- createXPost (xConfig cfg) postText [mediaId]
47-
putStrLn $ "X post: " ++ if xSuccess then "success" else "failed"
48-
Nothing -> putStrLn "failed to upload image to X"
49-
5053
removeFile tempPath
5154
else putStrLn "failed to download image"
5255

@@ -59,21 +62,21 @@ sleep minutes = do
5962
main :: IO ()
6063
main = forever $ do
6164
cfg <- loadConfig "config.json"
62-
63-
files <- listIPFSDirectory (ipfsConfig cfg)
64-
let jpgFiles = filter (isJPG . name) files
65-
66-
putStrLn $ "found " ++ show (length jpgFiles) ++ " JPG files"
67-
68-
case jpgFiles of
69-
[] -> do
70-
putStrLn "no files to process"
71-
sleep (deserializeInterval (intervalMinutes cfg))
72-
bits -> do
73-
-- Select a random file
74-
randomIndex <- randomRIO (0, length bits - 1)
75-
let selectedBit = bits !! randomIndex
76-
77-
putStrLn $ "randomly selected: " ++ T.unpack (name selectedBit)
78-
processFile cfg selectedBit
65+
entries <- listPinataDirectory (ipfsConfig cfg)
66+
case entries of
67+
Left err -> do
68+
putStrLn $ "error listing directory: " ++ err
7969
sleep (deserializeInterval (intervalMinutes cfg))
70+
Right entries' -> do
71+
let validFiles = filter (isValidImage . name) entries'
72+
putStrLn $ "found " ++ show (length validFiles) ++ " valid image files"
73+
case validFiles of
74+
[] -> do
75+
putStrLn "no files to process"
76+
sleep (deserializeInterval (intervalMinutes cfg))
77+
bits -> do
78+
randomIndex <- randomRIO (0, length bits - 1)
79+
let selectedBit = bits !! randomIndex
80+
putStrLn $ "randomly selected: " ++ T.unpack (name selectedBit)
81+
processFile cfg selectedBit
82+
sleep (deserializeInterval (intervalMinutes cfg))

‎app/Main.hs.backup

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
module Main where
2+
3+
import Config
4+
import Control.Concurrent (threadDelay)
5+
import Control.Exception (try)
6+
import Control.Monad (forever)
7+
import qualified Data.ByteString.Lazy as BL
8+
import Data.Char (isAlphaNum)
9+
import qualified Data.Text as T
10+
import Farcaster
11+
import IPFS
12+
import Network.HTTP.Simple
13+
import System.Directory (createDirectoryIfMissing, removeFile)
14+
import System.FilePath ((</>))
15+
import System.Random (randomRIO)
16+
import X
17+
18+
downloadImage :: T.Text -> FilePath -> IO Bool
19+
downloadImage url path = do
20+
request <- parseRequest (T.unpack url)
21+
response <- try $ httpLBS request :: IO (Either HttpException (Response BL.ByteString)) -- Add type annotation
22+
case response of
23+
Right r -> do
24+
BL.writeFile path (getResponseBody r)
25+
return True
26+
Left err -> do
27+
putStrLn $ "error downloading image: " ++ show err
28+
return False
29+
30+
isValidImage :: T.Text -> Bool
31+
isValidImage fileName =
32+
let lowercase = T.toLower fileName
33+
in T.isSuffixOf ".jpg" lowercase
34+
&& not (T.isPrefixOf "." lowercase) -- excludes .DS_Store and other hidden files
35+
&& not (T.isPrefixOf "_" lowercase) -- excludes _thumb files etc
36+
&& T.all isAllowedChar fileName -- ensures filename only contains safe characters
37+
where
38+
isAllowedChar c = isAlphaNum c || c `elem` ['.', '-', '_']
39+
40+
processFile :: Config -> DirectoryEntry -> IO ()
41+
processFile cfg entry = do
42+
let ipfsUrl = makeIPFSUrl (ipfsConfig cfg) entry
43+
putStrLn $ "processing: " ++ T.unpack (name entry)
44+
let tempPath = "temp" </> T.unpack (name entry)
45+
createDirectoryIfMissing True "temp"
46+
downloadSuccess <- downloadImage ipfsUrl tempPath
47+
if downloadSuccess
48+
then do
49+
let postText = "A bit of Saturn"
50+
51+
farcasterSuccess <- postCastWithImage (farcasterConfig cfg) postText ipfsUrl
52+
putStrLn $ "farcaster post: " ++ if farcasterSuccess then "success" else "failed"
53+
54+
-- mediaResult <- uploadToX (xConfig cfg) tempPath
55+
-- putStrLn $ "media upload: " ++ show mediaResult
56+
-- case mediaResult of
57+
-- Left err -> putStrLn $ "media upload failed: " ++ show err
58+
-- Right mediaId -> do
59+
-- postResult <- createXPost (xConfig cfg) postText [mediaId]
60+
-- case postResult of
61+
-- Left err -> putStrLn $ "x post failed: " ++ show err
62+
-- Right () -> putStrLn "x post success"
63+
64+
removeFile tempPath
65+
else putStrLn "failed to download image"
66+
67+
sleep :: Int -> IO ()
68+
sleep minutes = do
69+
let microseconds = minutes * 60 * 1000000
70+
putStrLn $ "\nsleeping for " ++ show minutes ++ " minutes..."
71+
threadDelay microseconds
72+
73+
main :: IO ()
74+
main = forever $ do
75+
cfg <- loadConfig "config.json"
76+
entries <- listPinataDirectory (ipfsConfig cfg)
77+
case entries of
78+
Left err -> do
79+
putStrLn $ "error listing directory: " ++ err
80+
sleep (deserializeInterval (intervalMinutes cfg))
81+
Right entries' -> do
82+
let validFiles = filter (isValidImage . name) entries'
83+
putStrLn $ "found " ++ show (length validFiles) ++ " valid image files"
84+
case validFiles of
85+
[] -> do
86+
putStrLn "no files to process"
87+
sleep (deserializeInterval (intervalMinutes cfg))
88+
bits -> do
89+
randomIndex <- randomRIO (0, length bits - 1)
90+
let selectedBit = bits !! randomIndex
91+
putStrLn $ "randomly selected: " ++ T.unpack (name selectedBit)
92+
processFile cfg selectedBit
93+
sleep (deserializeInterval (intervalMinutes cfg))

‎app/Social.hs

-91
This file was deleted.

‎app/X.hs.backup

+181
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
{-# LANGUAGE OverloadedStrings #-}
2+
{-# LANGUAGE ScopedTypeVariables #-}
3+
4+
module X (uploadToX, createXPost, MediaUploadError(..)) where
5+
6+
import Config (XConfig(..))
7+
import Control.Exception (SomeException, try)
8+
import GHC.Generics (Generic)
9+
import Data.Aeson (FromJSON, Value(..), object, (.=), eitherDecode, encode)
10+
import qualified Data.Aeson.KeyMap as KM
11+
import qualified Data.ByteString as BS
12+
import qualified Data.ByteString.Lazy.Char8 as BL8
13+
import qualified Data.ByteString.Char8 as BS8
14+
import qualified Data.Text as T
15+
import qualified Data.Text.Encoding as TE
16+
import Network.HTTP.Client
17+
import Network.HTTP.Client.MultipartFormData
18+
import Network.HTTP.Client.TLS (tlsManagerSettings)
19+
import Network.HTTP.Types (methodPost, status200)
20+
import System.IO (hPutStrLn, stderr)
21+
import System.Environment (getEnv, setEnv)
22+
23+
data MediaUploadError
24+
= FileReadError
25+
| NetworkError String
26+
| ParseError String
27+
deriving (Show, Eq)
28+
29+
data TokenResponse = TokenResponse
30+
{ access_token :: T.Text
31+
, refresh_token :: T.Text
32+
, expires_in :: Int
33+
} deriving (Show, Generic)
34+
35+
instance FromJSON TokenResponse
36+
37+
refreshAccessToken :: XConfig -> IO (Either String T.Text)
38+
refreshAccessToken cfg = do
39+
manager <- newManager tlsManagerSettings
40+
request <- parseRequest "https://api.x.com/2/oauth2/token"
41+
42+
let authToken = "Basic " <> TE.encodeUtf8 (xBasicAuthToken cfg)
43+
44+
let body = BS8.pack $ "client_id=" ++ T.unpack (xClientId cfg)
45+
++ "&refresh_token=" ++ T.unpack (xRefreshToken cfg)
46+
++ "&grant_type=refresh_token"
47+
48+
let request' = request
49+
{ method = "POST"
50+
, requestHeaders =
51+
[ ("Content-Type", "application/x-www-form-urlencoded")
52+
, ("Authorization", authToken)
53+
]
54+
, requestBody = RequestBodyBS body
55+
}
56+
57+
response <- httpLbs request' manager
58+
let bodyContent = responseBody response
59+
60+
print bodyContent -- TODO: remove
61+
62+
case eitherDecode bodyContent of
63+
Left err -> do
64+
hPutStrLn stderr $ "failed to refresh token: " ++ err
65+
return $ Left err
66+
Right tokenResponse -> do
67+
token <- getEnv "X_REFRESH_TOKEN"
68+
print token
69+
setEnv "X_REFRESH_TOKEN" (T.unpack $ refresh_token tokenResponse)
70+
token <- getEnv "X_REFRESH_TOKEN"
71+
print token
72+
return $ Right (access_token tokenResponse)
73+
74+
prepareAuthHeader :: XConfig -> Request -> IO Request
75+
prepareAuthHeader cfg req = do
76+
tokenResult <- refreshAccessToken cfg
77+
case tokenResult of
78+
Left err -> do
79+
hPutStrLn stderr $ "warning: using stale token due to error: " ++ err
80+
return req
81+
Right token -> return req { requestHeaders = ("Authorization", "Bearer " <> TE.encodeUtf8 token) : requestHeaders req }
82+
83+
uploadToX :: XConfig -> FilePath -> IO (Either MediaUploadError T.Text)
84+
uploadToX cfg filePath = do
85+
imageDataResult <- try $ BS.readFile filePath
86+
case imageDataResult of
87+
Left (_ :: SomeException) -> return $ Left FileReadError
88+
Right imageData -> do
89+
manager <- newManager tlsManagerSettings
90+
91+
-- Step 1: INIT
92+
initRequest <- parseRequest "https://api.x.com/2/media/upload"
93+
94+
let totalBytes = BS.length imageData
95+
mediaType = "image/jpeg"
96+
mediaCategory = "tweet_image"
97+
98+
let initRequestWithBody = initRequest { method = "POST" }
99+
requestWithBody <- formDataBody
100+
[ partBS "command" "INIT"
101+
, partBS "media_type" (BS8.pack mediaType)
102+
, partBS "total_bytes" (BS8.pack (show totalBytes))
103+
, partBS "media_category" (BS8.pack mediaCategory)
104+
] initRequestWithBody
105+
106+
initRequest' <- prepareAuthHeader cfg requestWithBody
107+
108+
print initRequest'
109+
110+
initResponse <- httpLbs initRequest' manager
111+
let initResponseBody = responseBody initResponse
112+
113+
print initResponseBody
114+
115+
case eitherDecode initResponseBody of
116+
Left err -> return $ Left $ ParseError err
117+
Right (Object initObj) -> case KM.lookup "media_id" initObj of
118+
Just (String mediaId) -> do
119+
-- Step 2: APPEND
120+
let appendParams = object
121+
[ "command" .= ("APPEND" :: T.Text)
122+
, "media_id" .= mediaId
123+
, "segment_index" .= (0 :: Int)
124+
]
125+
126+
appendRequest <- parseRequest "https://api.x.com/2/media/upload"
127+
appendRequest' <- prepareAuthHeader cfg appendRequest
128+
{ method = methodPost
129+
, requestHeaders = ("Content-Type", "multipart/form-data") : requestHeaders appendRequest
130+
, requestBody = RequestBodyLBS (encode appendParams)
131+
}
132+
133+
appendResponse <- httpNoBody appendRequest' manager
134+
if responseStatus appendResponse /= status200
135+
then return $ Left $ NetworkError "APPEND failed"
136+
else do
137+
-- Step 3: FINALIZE
138+
let finalizeParams = object
139+
[ "command" .= ("FINALIZE" :: T.Text)
140+
, "media_id" .= mediaId
141+
]
142+
143+
finalizeRequest <- parseRequest "https://api.x.com/2/media/upload"
144+
finalizeRequest' <- prepareAuthHeader cfg finalizeRequest
145+
{ method = "POST"
146+
, requestHeaders = ("Content-Type", "multipart/form-data") : requestHeaders finalizeRequest
147+
, requestBody = RequestBodyLBS (encode finalizeParams)
148+
}
149+
150+
finalizeResponse <- httpLbs finalizeRequest' manager
151+
let finalizeResponseBody = responseBody finalizeResponse
152+
case eitherDecode finalizeResponseBody of
153+
Left err -> return $ Left $ ParseError err
154+
Right (Object finalizeObj) -> case KM.lookup "media_id" finalizeObj of
155+
Just (String finalMediaId) -> return $ Right finalMediaId
156+
_ -> return $ Left $ ParseError "missing media_id in FINALIZE response"
157+
_ -> return $ Left $ ParseError "missing media_id in INIT response"
158+
159+
createXPost :: XConfig -> T.Text -> [T.Text] -> IO (Either MediaUploadError ())
160+
createXPost cfg postText mediaIds = do
161+
manager <- newManager tlsManagerSettings
162+
163+
initialRequest <- parseRequest "https://api.x.com/2/tweets"
164+
165+
let params = object
166+
[ "text" .= postText
167+
, "media" .= object [ "media_ids" .= mediaIds ]
168+
]
169+
170+
request <- prepareAuthHeader cfg initialRequest
171+
{ method = "POST"
172+
, requestHeaders = ("Content-Type", "application/json") : requestHeaders initialRequest
173+
, requestBody = RequestBodyLBS (encode params)
174+
}
175+
176+
responseEither <- try $ httpLbs request manager
177+
case responseEither of
178+
Right response -> do
179+
putStrLn $ "Response body: " ++ show (responseBody response)
180+
return $ Right ()
181+
Left (ex :: SomeException) -> return $ Left $ NetworkError $ show ex

‎bitsofsaturn.cabal

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ executable bits-of-saturn
1616
main-is: Main.hs
1717
other-modules: Config
1818
, IPFS
19-
, Social
19+
, Farcaster
2020
build-depends: base ^>= 4.18.0.0
2121
, aeson
2222
, bytestring
2323
, directory
2424
, filepath
25+
, http-client
2526
, http-conduit
2627
, random
2728
, text

‎config.json

+4-8
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
{
22
"ipfsConfig": {
33
"gateway": "${IPFS_GATEWAY}",
4-
"folderCID": "${IPFS_FOLDER_CID}"
4+
"folderCID": "${IPFS_FOLDER_CID}",
5+
"pinataToken": "${PINATA_TOKEN}"
56
},
67
"farcasterConfig": {
7-
"authToken": "${FARCASTER_AUTH_TOKEN}"
8-
},
9-
"xConfig": {
10-
"apiKey": "${X_API_KEY}",
11-
"apiSecret": "${X_API_SECRET}",
12-
"accessToken": "${X_ACCESS_TOKEN}",
13-
"accessSecret": "${X_ACCESS_SECRET}"
8+
"neynarApiKey": "${NEYNAR_API_KEY}",
9+
"neynarUuid": "${NEYNAR_UUID}"
1410
},
1511
"intervalMinutes": "${INTERVAL_MINUTES}"
1612
}

‎temp/N00032852.jpg

28.1 KB
Loading

‎temp/N00069595.jpg

32.4 KB
Loading

0 commit comments

Comments
 (0)
Please sign in to comment.