REBOL [
    Title: "Twitter Client for Rebol"
    Date: 2-May-2016
    Author: "Christopher Ross-Gill"
    Version: 0.3.5
    Rights: http://creativecommons.org/licenses/by-sa/3.0/
    File: %twitter.r
    Purpose: {Rebol script to access and use the Twitter OAuth API.}
    Settings: [
        ; use if not working with 'do/args
        Consumer-Key: <consumer-key>
        Consumer-Secret: <consumer-secret>
        User-Store: <path-to-saved-users>
    ]
]

do http://reb4.me/r/altwebform
do http://reb4.me/r/altjson

twitter: context bind [
    as: func [
        [catch]
        "Set current user"
        user [string!] "Twitter user name"
    ][
        either user: select users user [
            persona: make persona user
            persona/name
        ][
            either not error? user: try [register][
                repend users [
                    user/name
                    new-line/skip/all third user true 2
                ]
                persona/name
            ][throw :user]
        ]
    ]

    save-users: func [
        "Saves authorized users"
        /to location [file! url!] "Alternate Storage Location"
    ][
        location: any [location settings/user-store]
        unless any [file? location url? location][
            make error! "No Storage Location Provided"
        ]
        save/header location new-line/skip/all users true 2 context [
            Title: "Twitter Authorized Users"
            Date: now/date
        ]
    ]

    authorized-users: func ["Lists authorized users"][extract users 2] 

    find: func [
        "Tweets by Search" [catch]
        query [string! issue! email!] "Search String"
        /size count [integer!] /page offset [integer!]
    ][
        case [
            issue? query [query: mold query]
            email? query [query: join "@" query/host]
        ]

        set params reduce [query offset count]

        either attempt [
            result: send/with 'get %1.1/search/tweets.json params
        ] load-result error/connection
    ]

    timeline: func [
        "Retrieve a User Timeline" [catch]
        /for user [string!] /size count [integer!] /page offset [integer!]
    ][
        unless persona/name error/credentials

        set options reduce [
            any [user persona/name]
            all [count min 200 abs count]
            offset
        ]

        either attempt [
            result: send/with 'get %1.1/statuses/user_timeline.json options
        ] load-result error/connection
    ]

    home: friends: func [
        "Retrieve status messages from friends" [catch]
        /size count [integer!] /page offset [integer!]
    ][
        unless persona/name error/credentials

        set options reduce [
            none
            all [count min 200 abs count]
            offset
        ]

        either attempt [
            result: send/with 'get %1.1/statuses/home_timeline.json options
        ] load-result error/connection
    ]

    update: func [
        "Send Twitter status update" [catch]
        status [string!] "Status message"
        /reply "As reply to" id [issue!] "Reply reference" /override
    ][
        override: either override [200][140]
        unless persona/name error/credentials
        unless all [0 < length? status override > length? status] error/invalid

        set message reduce [status id]

        either attempt [
            result: send/with 'post %1.1/statuses/update.json message
        ] load-result error/connection
    ]

] context [ ; internals
    twitter: https://api.twitter.com/

    options: context [screen_name: count: page: none]
    params: context [q: page: rpp: none]
    message: context [status: in_reply_to_status_id: none]

    result: none
    load-result: [load-json result]

    error: [
        credentials [throw make error! "User must be authorized to use this application"]
        connection [throw make error! "Unable to connect to Twitter"]
        invalid [throw make error! "Status length should be between between 1 and 140"]
    ]

    settings: make context [
        consumer-key: consumer-secret: user-store: none
    ] any [
        system/script/args
        system/script/header/settings
    ]

    users: any [attempt [load settings/user-store] []]

    persona: context [
        id: name: none
        token: secret: none
    ]

    oauth!: context [
        oauth_callback: none
        oauth_consumer_key: settings/consumer-key
        oauth_token: oauth_nonce: none
        oauth_signature_method: "HMAC-SHA1"
        oauth_timestamp: none
        oauth_version: 1.0
        oauth_verifier: oauth_signature: none
    ]

    send: use [make-nonce timestamp sign][
        make-nonce: does [
            enbase/base checksum/secure join now/precise settings/consumer-key 64
        ]

        timestamp: func [/for date [date!]][
            date: any [date now]
            date: form any [
                attempt [to integer! difference date 1-Jan-1970/0:0:0]
                date - 1-Jan-1970/0:0:0 * 86400.0
            ]
            clear find/last date "."
            date
        ]

        sign: func [
            method [word!]
            lookup [url!]
            oauth [object! block! none!]
            params [object! block! none!]
            /local out
        ][
            out: copy ""

            oauth: any [oauth make oauth! []]
            oauth/oauth_nonce: make-nonce
            oauth/oauth_timestamp: timestamp
            oauth/oauth_token: persona/token

            params: make oauth any [params []]
            params: sort/skip third params 2

            oauth/oauth_signature: enbase/base checksum/secure/key rejoin [
                uppercase form method "&" url-encode form lookup "&"
                url-encode replace/all to-webform params "+" "%20"
            ] rejoin [
                settings/consumer-secret "&" any [persona/secret ""]
            ] 64

            foreach [header value] third oauth [
                if value [
                    repend out [", " form header {="} url-encode form value {"}]
                ]
            ]

            join "OAuth" next out
        ]

        send: func [
            [catch]
            method [word!] lookup [file!]
            /auth oauth [object!]
            /with params [object!]
        ][
            lookup: twitter/:lookup
            oauth: make oauth! any [oauth []]
            if object? params [params: third params]

            switch method [
                put delete [
                    params: compose [method: (uppercase form method) (any [params []])]
                    method: 'post
                ]
            ]

            switch method [
                get [
                    method: compose/deep [
                        header [Authorization: (sign 'get lookup oauth params)]
                    ]
                    if params [
                        params: context sort/skip params 2
                        append lookup to-webform/prefix params
                    ]
                ]
                post put delete [
                    method: compose/deep [
                        (method) (either params [to-webform params][""]) [
                            Authorization: (sign method lookup oauth params)
                            Content-Type: "application/x-www-form-urlencoded"
                        ]
                    ]
                ]
            ]

            lookup: read/custom lookup method
        ]
    ]

    register: use [request-broker access-broker verification-page][
        request-broker: %oauth/request_token
        verification-page: %oauth/authorize?oauth_token=
        access-broker: %oauth/access_token

        func [
            /requester request [function!]
            /local response verifier
        ][
            request: any [:request :ask]
            set persona none

            response: load-webform send/auth 'post request-broker make oauth! [
                oauth_callback: "oob"
            ]

            persona/token: response/oauth_token
            persona/secret: response/oauth_token_secret

            browse join twitter/:verification-page response/oauth_token 
            unless verifier: request "Enter your PIN from Twitter: " [
                make error! "Not a valid PIN"
            ]

            response: load-webform send/auth 'post access-broker make oauth! [
                oauth_verifier: trim/all verifier
            ]

            persona/id: to-issue response/user_id
            persona/name: response/screen_name
            persona/token: response/oauth_token
            persona/secret: response/oauth_token_secret

            persona
        ]
    ]
]