All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.github.twitch4j.pubsub.TwitchPubSub Maven / Gradle / Ivy

The newest version!
package com.github.twitch4j.pubsub;

import com.github.philippheuer.credentialmanager.domain.OAuth2Credential;
import com.github.philippheuer.events4j.core.EventManager;
import com.github.twitch4j.client.websocket.WebsocketConnection;
import com.github.twitch4j.client.websocket.domain.WebsocketConnectionState;
import com.github.twitch4j.common.config.ProxyConfig;
import com.github.twitch4j.common.events.TwitchEvent;
import com.github.twitch4j.common.util.CryptoUtils;
import com.github.twitch4j.common.util.TimeUtils;
import com.github.twitch4j.common.util.TypeConvert;
import com.github.twitch4j.pubsub.domain.PubSubRequest;
import com.github.twitch4j.pubsub.domain.PubSubResponse;
import com.github.twitch4j.pubsub.domain.PubSubResponsePayload;
import com.github.twitch4j.pubsub.enums.PubSubType;
import com.github.twitch4j.pubsub.events.PubSubAuthRevokeEvent;
import com.github.twitch4j.pubsub.events.PubSubConnectionStateEvent;
import com.github.twitch4j.pubsub.events.PubSubListenResponseEvent;
import com.github.twitch4j.pubsub.handlers.HandlerRegistry;
import com.github.twitch4j.pubsub.handlers.TopicHandler;
import com.github.twitch4j.util.IBackoffStrategy;
import lombok.Getter;
import lombok.SneakyThrows;
import lombok.Synchronized;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Twitch PubSub
 */
@Slf4j
public class TwitchPubSub implements ITwitchPubSub {

    public static final int REQUIRED_THREAD_COUNT = 1;

    private static final Pattern LISTEN_AUTH_TOKEN = Pattern.compile("(\\{.*\"type\"\\s*?:\\s*?\"LISTEN\".*\"data\"\\s*?:\\s*?\\{.*\"auth_token\"\\s*?:\\s*?\").+(\".*}\\s*?})");

    /**
     * EventManager
     */
    @Getter
    private final EventManager eventManager;

    /**
     * WebSocket Connection
     */
    private final WebsocketConnection connection;

    /**
     * The WebSocket Server
     */
    private static final String WEB_SOCKET_SERVER = "wss://pubsub-edge.twitch.tv:443";

    /**
     * Whether {@link #flushCommand} is currently executing
     */
    private final AtomicBoolean flushing = new AtomicBoolean();

    /**
     * Whether an expedited flush has already been submitted
     */
    private final AtomicBoolean flushRequested = new AtomicBoolean();

    /**
     * The {@link Runnable} for flushing the {@link #commandQueue}
     */
    private final Runnable flushCommand;

    /**
     * Command Queue Thread
     */
    protected final Future queueTask;

    /**
     * Heartbeat Thread
     */
    protected final Future heartbeatTask;

    /**
     * is Closed?
     */
    protected volatile boolean isClosed = false;

    /**
     * Command Queue
     */
    protected final BlockingQueue commandQueue = new ArrayBlockingQueue<>(128);

    /**
     * Holds the subscribed topics in case we need to reconnect
     */
    protected final Set subscribedTopics = ConcurrentHashMap.newKeySet();

    /**
     * Last Ping send (1 minute delay before sending the first ping)
     */
    protected volatile long lastPing = TimeUtils.getCurrentTimeInMillis() - 4 * 60 * 1000;

    /**
     * Last Pong received
     */
    protected volatile long lastPong = TimeUtils.getCurrentTimeInMillis();

    /**
     * Thread Pool Executor
     */
    protected final ScheduledExecutorService taskExecutor;

    /**
     * Bot Owner IDs
     */
    private final Collection botOwnerIds;

    /**
     * Fallback Topic Handler
     */
    private final Consumer fallbackTopicHandler;

    /**
     * Constructor
     *
     * @param websocketConnection       WebsocketConnection
     * @param eventManager              EventManager
     * @param taskExecutor              ScheduledThreadPoolExecutor
     * @param proxyConfig               ProxyConfig
     * @param botOwnerIds               Bot Owner IDs
     * @param wsPingPeriod              WebSocket Ping Period
     * @param connectionBackoffStrategy WebSocket Connection Backoff Strategy
     * @param wsCloseDelay              Websocket Close Delay
     * @param fallbackTopicHandler      Fallback Topic Handler
     */
    public TwitchPubSub(WebsocketConnection websocketConnection, EventManager eventManager, ScheduledThreadPoolExecutor taskExecutor, ProxyConfig proxyConfig, Collection botOwnerIds, int wsPingPeriod, IBackoffStrategy connectionBackoffStrategy, int wsCloseDelay, Consumer fallbackTopicHandler) {
        this.eventManager = eventManager;
        this.taskExecutor = taskExecutor;
        this.botOwnerIds = botOwnerIds;

        if (fallbackTopicHandler != null) {
            this.fallbackTopicHandler = fallbackTopicHandler;
        } else {
            this.fallbackTopicHandler = message -> log.warn("Unparsable Message: " + message.getTopic() + "|" + message.getMessage());
        }

        // init connection
        if (websocketConnection == null) {
            this.connection = new WebsocketConnection(spec -> {
                spec.baseUrl(WEB_SOCKET_SERVER);
                spec.closeDelay(wsCloseDelay);
                spec.wsPingPeriod(wsPingPeriod);
                spec.onStateChanged((oldState, newState) -> eventManager.publish(new PubSubConnectionStateEvent(oldState, newState, this)));
                spec.onPreConnect(this::onPreConnect);
                spec.onConnected(this::onConnected);
                spec.onTextMessage(this::onTextMessage);
                spec.onPostDisconnect(commandQueue::clear);
                spec.taskExecutor(taskExecutor);
                spec.proxyConfig(proxyConfig);
                if (connectionBackoffStrategy != null)
                    spec.backoffStrategy(connectionBackoffStrategy);
            });
        } else {
            this.connection = websocketConnection;
        }

        // register with serviceMediator
        this.eventManager.getServiceMediator().addService("twitch4j-pubsub", this);

        // connect
        this.connect();

        // Run heartbeat every 4 minutes
        heartbeatTask = taskExecutor.scheduleAtFixedRate(() -> {
            if (isClosed || connection.getConnectionState() != WebsocketConnectionState.CONNECTED)
                return;

            PubSubRequest request = new PubSubRequest();
            request.setType(PubSubType.PING);
            sendCommand(TypeConvert.objectToJson(request));

            log.debug("PubSub: Sending PING!");
            lastPing = TimeUtils.getCurrentTimeInMillis();
        }, 0, 4L, TimeUnit.MINUTES);

        // Runnable for flushing the command queue
        this.flushCommand = () -> {
            // Only allow a single thread to flush at a time
            if (flushing.getAndSet(true))
                return;

            // Attempt to flush the queue
            while (!isClosed) {
                try {
                    // check for missing pong response
                    if (lastPong < lastPing && TimeUtils.getCurrentTimeInMillis() >= lastPing + 10000) {
                        log.warn("PubSub: Didn't receive a PONG response in time, reconnecting to obtain a connection to a different server.");
                        reconnect();
                        break;
                    }

                    // If connected, send one message from the queue
                    if (WebsocketConnectionState.CONNECTED.equals(connection.getConnectionState())) {
                        String command = commandQueue.poll();
                        if (command != null) {
                            sendCommand(command);
                            // Logging
                            if (log.isDebugEnabled()) {
                                Matcher matcher = LISTEN_AUTH_TOKEN.matcher(command);
                                String cmd = matcher.find() ? matcher.group(1) + "\u2022\u2022\u2022" + matcher.group(2) : command;
                                log.debug("Processed command from queue: [{}].", cmd);
                            }
                        } else {
                            break; // try again later
                        }
                    } else {
                        break; // try again later
                    }
                } catch (Exception ex) {
                    log.error("PubSub: Unexpected error in worker thread", ex);
                    break;
                }
            }

            // Indicate that flushing has completed
            flushRequested.set(false);
            flushing.set(false);
        };

        // queue command worker
        this.queueTask = taskExecutor.scheduleWithFixedDelay(flushCommand, 0, 2500L, TimeUnit.MILLISECONDS);

        log.debug("PubSub: Started Queue Worker Thread");
    }

    /**
     * Connecting to IRC-WS
     */
    public void connect() {
        connection.connect();
    }

    /**
     * Disconnecting from WebSocket
     */
    public void disconnect() {
        connection.disconnect();
    }

    /**
     * Reconnecting to WebSocket
     */
    @Synchronized
    public void reconnect() {
        connection.reconnect();
    }

    protected void onPreConnect() {
        // Reset last ping to avoid edge case loop where reconnect occurred after sending PING but before receiving PONG
        lastPong = TimeUtils.getCurrentTimeInMillis();
        lastPing = lastPong - 4 * 60 * 1000;
    }

    protected void onConnected() {
        log.info("Connected to Twitch PubSub {}", WEB_SOCKET_SERVER);

        // resubscribe to all topics after disconnect
        // This involves nonce reuse, which is bad cryptography, but not a serious problem for this context
        // To avoid reuse, we can:
        // 0) stop other threads from updating subscribedTopics
        // 1) create a new PubSubRequest for each element of subscribedTopics (with a new nonce)
        // 2) clear subscribedTopics
        // 3) allow other threads to update subscribedTopics again
        // 4) send unlisten requests for the old elements of subscribedTopics (optional?)
        // 5) call listenOnTopic for each new PubSubRequest
        subscribedTopics.forEach(this::queueRequest);
    }

    protected void onTextMessage(String text) {
        try {
            log.trace("Received WebSocketMessage: " + text);

            // parse message
            PubSubResponse message = TypeConvert.jsonToObject(text, PubSubResponse.class);
            if (message.getType().equals(PubSubType.MESSAGE)) {
                String topic = message.getData().getTopic();
                String[] topicParts = StringUtils.split(topic, '.');
                String topicName = topicParts[0];

                // Handle Messages
                TopicHandler handler = HandlerRegistry.INSTANCE.getHandlers().get(topicName);
                boolean fallback = true;
                if (handler != null) {
                    TwitchEvent event = null;
                    try {
                        event = handler.apply(new TopicHandler.Args(topicParts, message.getData().getMessage(), botOwnerIds));
                    } catch (Exception e) {
                        log.warn("PubSub: Encountered exception when parsing message", e);
                    }

                    if (event != null) {
                        fallback = false;
                        try {
                            eventManager.publish(event);
                        } catch (Exception e) {
                            log.warn("An event consumer threw an exception while processing a PubSub event", e);
                        }
                    }
                }
                if (fallback) {
                    fallbackTopicHandler.accept(message.getData());
                }
            } else if (message.getType().equals(PubSubType.RESPONSE)) {
                Supplier findListenRequest = () -> {
                    for (PubSubRequest topic : subscribedTopics) {
                        if (topic != null && StringUtils.equals(message.getNonce(), topic.getNonce())) {
                            return topic;
                        }
                    }
                    return null;
                };

                eventManager.publish(new PubSubListenResponseEvent(message.getNonce(), message.getError(), findListenRequest));

                // topic subscription success or failed, response to listen command
                // System.out.println(message.toString());
                if (message.getError().length() > 0) {
                    if (message.getError().equalsIgnoreCase("ERR_BADAUTH")) {
                        log.error("PubSub: You used a invalid oauth token to subscribe to the topic. Please use a token that is authorized for the specified channel.");
                    } else {
                        log.error("PubSub: Failed to subscribe to topic - [" + message.getError() + "]");
                    }
                }

            } else if (message.getType().equals(PubSubType.PONG)) {
                log.debug("PubSub: Received PONG response!");
                lastPong = TimeUtils.getCurrentTimeInMillis();
            } else if (message.getType().equals(PubSubType.RECONNECT)) {
                log.warn("PubSub: Server instance we're connected to will go down for maintenance soon, reconnecting to obtain a new connection!");
                reconnect();
            } else if (message.getType() == PubSubType.AUTH_REVOKED) {
                PubSubRequest revocation = TypeConvert.jsonToObject(text, PubSubRequest.class);
                Object topicsObj = revocation.getData().get("topics");
                if (topicsObj instanceof Collection) {
                    Map revoked = new HashMap<>(); // allows for null values

                    // Read topic names
                    for (Object topicObj : (Collection) topicsObj) {
                        if (topicObj instanceof String) {
                            revoked.put((String) topicObj, null);
                        } else {
                            log.warn("Unparsable Revocation Topic: {}", topicObj);
                        }
                    }

                    if (revoked.isEmpty())
                        return; // should not occur

                    // Unsubscribe
                    subscribedTopics.removeIf(req -> {
                        Object topics = req.getData().get("topics");
                        if (topics instanceof Collection && ((Collection) topics).size() == 1) {
                            Object topic = ((Collection) topics).iterator().next();
                            return topic instanceof String && revoked.replace((String) topic, null, req);
                        }
                        return false;
                    });

                    // Fire event
                    eventManager.publish(new PubSubAuthRevokeEvent(this, Collections.unmodifiableMap(revoked)));
                } else {
                    log.warn("Unparsable Revocation: {}", text);
                }
            } else {
                // unknown message
                log.debug("PubSub: Unknown Message Type: " + message);
            }
        } catch (Exception ex) {
            log.warn("PubSub: Unparsable Message: " + text + " - [" + ex.getMessage() + "]", ex);
        }
    }

    /**
     * Send WS Message
     *
     * @param command IRC Command
     */
    private void sendCommand(String command) {
        // will send command if connection has been established
        if (WebsocketConnectionState.CONNECTED.equals(connection.getConnectionState()) || WebsocketConnectionState.CONNECTING.equals(connection.getConnectionState())) {
            connection.sendText(command);
        } else {
            log.warn("Can't send IRC-WS Command [{}]", command);
        }
    }

    /**
     * Queue PubSub request
     *
     * @param request PubSub request (or Topic)
     */
    private void queueRequest(PubSubRequest request) {
        // use latest token (in case of expiry)
        OAuth2Credential credential = request.getCredential();
        if (credential != null) {
            request.getData().put("auth_token", credential.getAccessToken());
        }

        // queue the request
        commandQueue.add(TypeConvert.objectToJson(request));

        // Expedite command execution if we aren't already flushing the queue and another expedition hasn't already been requested
        if (!flushing.get() && !flushRequested.getAndSet(true))
            taskExecutor.schedule(this.flushCommand, 50L, TimeUnit.MILLISECONDS); // allow for some accumulation of requests before flushing
    }

    @Override
    public PubSubSubscription listenOnTopic(PubSubRequest request) {
        if (subscribedTopics.add(request)) {
            checkListenCount(request);
            queueRequest(request);
        }
        return new PubSubSubscription(request);
    }

    @Override
    public boolean unsubscribeFromTopic(PubSubSubscription subscription) {
        PubSubRequest request = subscription.getRequest();
        if (request.getType() != PubSubType.LISTEN) {
            log.warn("Cannot unsubscribe using request with unexpected type: {}", request.getType());
            return false;
        }
        boolean removed = subscribedTopics.remove(request);
        if (!removed) {
            log.warn("Not subscribed to topic: {}", request);
            return false;
        }

        // use data from original request and send UNLISTEN
        PubSubRequest unlistenRequest = new PubSubRequest();
        unlistenRequest.setType(PubSubType.UNLISTEN);
        unlistenRequest.setNonce(CryptoUtils.generateNonce(30));
        unlistenRequest.setData(request.getData());
        queueRequest(unlistenRequest);
        return true;
    }

    public long getLatency() {
        return connection.getLatency();
    }

    /**
     * Close
     */
    @SneakyThrows
    @Override
    public void close() {
        if (!isClosed) {
            isClosed = true;
            heartbeatTask.cancel(false);
            queueTask.cancel(false);
            connection.close();
        }
    }

    private void checkListenCount(PubSubRequest request) {
        Object topics = request.getData().get("topics");
        if (topics instanceof Collection && ((Collection) topics).size() > 1) {
            log.warn("Listening to multiple PubSub topics in a single request is not recommended; " +
                "automatic topic management can degrade upon PubSubAuthRevokeEvent");
        }
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy