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

com.github.twitch4j.chat.TwitchChat Maven / Gradle / Ivy

package com.github.twitch4j.chat;

import com.github.philippheuer.credentialmanager.CredentialManager;
import com.github.philippheuer.credentialmanager.domain.OAuth2Credential;
import com.github.philippheuer.events4j.core.EventManager;
import com.github.twitch4j.auth.providers.TwitchIdentityProvider;
import com.github.twitch4j.chat.enums.CommandSource;
import com.github.twitch4j.chat.enums.TMIConnectionState;
import com.github.twitch4j.chat.events.CommandEvent;
import com.github.twitch4j.chat.events.IRCEventHandler;
import com.github.twitch4j.chat.events.channel.ChannelMessageEvent;
import com.github.twitch4j.chat.events.channel.IRCMessageEvent;
import com.github.twitch4j.common.annotation.Unofficial;
import com.github.twitch4j.common.config.ProxyConfig;
import com.github.twitch4j.common.util.ChatReply;
import com.github.twitch4j.common.util.CryptoUtils;
import com.github.twitch4j.common.util.EscapeUtils;
import com.github.twitch4j.common.util.ExponentialBackoffStrategy;
import com.neovisionaries.ws.client.WebSocket;
import com.neovisionaries.ws.client.WebSocketAdapter;
import com.neovisionaries.ws.client.WebSocketFactory;
import com.neovisionaries.ws.client.WebSocketFrame;
import io.github.bucket4j.Bucket;
import lombok.Getter;
import lombok.Synchronized;
import lombok.extern.slf4j.Slf4j;

import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
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.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

@Slf4j
public class TwitchChat implements ITwitchChat {

    public static final int REQUIRED_THREAD_COUNT = 2;

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

    /**
     * CredentialManager
     */
    @Getter
    private final CredentialManager credentialManager;

    /**
     * OAuth2Credential, used to sign in to twitch chat
     */
    private OAuth2Credential chatCredential;

    /**
     * Twitch's official WebSocket Server
     */
    public static final String TWITCH_WEB_SOCKET_SERVER = "wss://irc-ws.chat.twitch.tv:443";

    /**
     * ThirdParty WebSocket Server for Testing
     */
    public static final String FDGT_TEST_SOCKET_SERVER = "wss://irc.fdgt.dev";

    /**
     * The websocket url for the chat client to connect to.
     */
    protected final String baseUrl;

    /**
     * Whether the {@link OAuth2Credential} password should be sent when the baseUrl does not
     * match the official twitch websocket server, thus bypassing a security check in the library.
     */
    protected final boolean sendCredentialToThirdPartyHost;

    /**
     * WebSocket Client
     */
    private volatile WebSocket webSocket;

    /**
     * The connection state
     * Default: ({@link TMIConnectionState#DISCONNECTED})
     */
    @Getter
    private volatile TMIConnectionState connectionState = TMIConnectionState.DISCONNECTED;

    /**
     * Channel Cache Lock
     */
    private final ReentrantLock channelCacheLock = new ReentrantLock();

    /**
     * Current Channels
     */
    protected final Set currentChannels = ConcurrentHashMap.newKeySet();

    /**
     * Cache: ChannelId to ChannelName
     */
    protected final Map channelIdToChannelName = new ConcurrentHashMap<>();

    /**
     * Cache: ChannelName to ChannelId
     */
    protected final Map channelNameToChannelId = new ConcurrentHashMap<>();

    /**
     * IRC Message Bucket
     */
    protected final Bucket ircMessageBucket;

    /**
     * IRC Whisper Bucket
     */
    protected final Bucket ircWhisperBucket;

    /**
     * IRC Command Queue
     */
    protected final BlockingQueue ircCommandQueue;

    /**
     * Whisper-specific Command Queue
     */
    protected final BlockingQueue whisperCommandQueue;

    /**
     * IRC Command Queue Thread
     */
    protected final ScheduledFuture queueThread;

    /**
     * Command Queue Thread stop flag
     */
    protected volatile Boolean stopQueueThread = false;

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

    /**
     * IRC Command Handlers
     */
    protected final List commandPrefixes;

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

    /**
     * Time to wait for an item on the chat queue before continuing to next iteration
     * If set too high your thread will be late check to shutdown
     */
    protected final long chatQueueTimeout;

    /**
     * WebSocket Factory
     */
    protected final WebSocketFactory webSocketFactory;

    /**
     * Whether one's own channel should automatically be joined
     */
    protected final boolean autoJoinOwnChannel;

    /**
     * Whether JOIN/PART events should be enabled
     */
    protected final boolean enableMembershipEvents;

    /**
     * Helper class to compute delays between connection retries.
     *
     * @see Official suggestion
     */
    protected final ExponentialBackoffStrategy backoff = ExponentialBackoffStrategy.builder()
        .immediateFirst(true)
        .baseMillis(Duration.ofSeconds(1).toMillis())
        .jitter(true)
        .multiplier(2.0)
        .maximumBackoff(Duration.ofMinutes(5).toMillis())
        .build();

    /**
     * Calls {@link ExponentialBackoffStrategy#reset()} upon a successful websocket connection
     */
    private volatile Future backoffClearer;

    /**
     * Constructor
     *
     * @param eventManager EventManager
     * @param credentialManager CredentialManager
     * @param chatCredential Chat Credential
     * @param baseUrl The websocket url for the chat client to connect to
     * @param sendCredentialToThirdPartyHost Whether the password should be sent when the baseUrl is not official
     * @param commandPrefixes Command Prefixes
     * @param chatQueueSize Chat Queue Size
     * @param ircMessageBucket Bucket for chat
     * @param ircWhisperBucket Bucket for whispers
     * @param taskExecutor ScheduledThreadPoolExecutor
     * @param chatQueueTimeout Timeout to wait for events in Chat Queue
     * @param proxyConfig Proxy Configuration
     * @param autoJoinOwnChannel Whether one's own channel should automatically be joined
     * @param enableMembershipEvents Whether JOIN/PART events should be enabled
     * @param botOwnerIds Bot Owner IDs
     */
    public TwitchChat(EventManager eventManager, CredentialManager credentialManager, OAuth2Credential chatCredential, String baseUrl, boolean sendCredentialToThirdPartyHost, List commandPrefixes, Integer chatQueueSize, Bucket ircMessageBucket, Bucket ircWhisperBucket, ScheduledThreadPoolExecutor taskExecutor, long chatQueueTimeout, ProxyConfig proxyConfig, boolean autoJoinOwnChannel, boolean enableMembershipEvents, Collection botOwnerIds) {
        this.eventManager = eventManager;
        this.credentialManager = credentialManager;
        this.chatCredential = chatCredential;
        this.baseUrl = baseUrl;
        this.sendCredentialToThirdPartyHost = sendCredentialToThirdPartyHost;
        this.commandPrefixes = commandPrefixes;
        this.botOwnerIds = botOwnerIds;
        this.ircCommandQueue = new ArrayBlockingQueue<>(chatQueueSize, true);
        this.whisperCommandQueue = new LinkedBlockingQueue<>();
        this.ircMessageBucket = ircMessageBucket;
        this.ircWhisperBucket = ircWhisperBucket;
        this.taskExecutor = taskExecutor;
        this.chatQueueTimeout = chatQueueTimeout;
        this.autoJoinOwnChannel = autoJoinOwnChannel;
        this.enableMembershipEvents = enableMembershipEvents;

        // Create WebSocketFactory and apply proxy settings
        this.webSocketFactory = new WebSocketFactory();
        if (proxyConfig != null)
            proxyConfig.applyWs(webSocketFactory.getProxySettings());

        // credential validation
        if (this.chatCredential == null) {
            log.info("TwitchChat: No ChatAccount provided, Chat will be joined anonymously! Please look at the docs Twitch4J -> Chat if this is unintentional");
        } else if (this.chatCredential.getUserName() == null) {
            log.info("TwitchChat: AccessToken does not contain any user information, fetching using the CredentialManager ...");

            // credential manager
            Optional credential = credentialManager.getOAuth2IdentityProviderByName("twitch")
                .orElse(new TwitchIdentityProvider(null, null, null))
                .getAdditionalCredentialInformation(this.chatCredential);
            if (credential.isPresent()) {
                this.chatCredential = credential.get();
            } else {
                log.error("TwitchChat: Failed to get AccessToken Information, the token is probably not valid. Please check the docs Twitch4J -> Chat on how to obtain a valid token.");
            }
        }

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

        // register event listeners
        IRCEventHandler ircEventHandler = new IRCEventHandler(this);

        // connect to irc
        this.connect();

        // queue command worker
        Runnable queueTask = () -> {
            while (!stopQueueThread) {
                String command = null;
                Bucket bucket;
                try {
                    // wait for queue, only have a timeout set to allow multiple loops to check stopQueueThread
                    // attempt to grab command from whisper queue before falling back to the general queue
                    if (!whisperCommandQueue.isEmpty() && ircWhisperBucket.tryConsume(1L)) {
                        ircWhisperBucket.addTokens(1L);
                        command = whisperCommandQueue.poll(this.chatQueueTimeout, TimeUnit.MILLISECONDS);
                        bucket = ircWhisperBucket;
                    } else {
                        command = ircCommandQueue.poll(this.chatQueueTimeout, TimeUnit.MILLISECONDS);
                        bucket = ircMessageBucket;
                    }

                    if (command != null) {
                        // Send the message, retrying forever until we are connected.
                        while (!stopQueueThread) {
                            if (connectionState.equals(TMIConnectionState.CONNECTED)) {
                                // block thread, until we can continue
                                bucket.asScheduler().consume(1);

                                sendTextToWebSocket(command, false);
                                break;
                            }
                            // Sleep for 25 milliseconds to wait for reconnection
                            TimeUnit.MILLISECONDS.sleep(25L);
                        }
                        // Logging
                        log.debug("Processed command from queue: [{}].", command.startsWith("PASS") ? "***OAUTH TOKEN HIDDEN***" : command);
                        log.debug("{} messages left before hitting the rate-limit!", ircMessageBucket.getAvailableTokens());
                    }
                } catch (Exception ex) {
                    log.error("Failed to process message from command queue", ex);

                    // Reschedule command for processing
                    if (command != null) {
                        try {
                            ircCommandQueue.put(command);
                        } catch (InterruptedException e) {
                            log.error("Failed to reschedule command", e);
                        }
                    }

                }
            }
        };

        // Thread will start right now
        this.queueThread = taskExecutor.schedule(queueTask, 1L, TimeUnit.MILLISECONDS);
        log.debug("Started IRC Queue Worker");

        // Event Handlers
        log.debug("Registering the following command triggers: " + commandPrefixes.toString());

        // register event handler
        eventManager.onEvent("twitch4j-chat-command-trigger", ChannelMessageEvent.class, this::onChannelMessage);
        eventManager.onEvent(IRCMessageEvent.class, event -> {
            // we get at least one room state event with channel name + id when we join a channel, so we cache that to provide channel id + name for all events
            if ("ROOMSTATE".equalsIgnoreCase(event.getCommandType())) {
                // check that channel id / name are present and that we didn't leave the channel yet
                if (event.getChannelId() != null) {
                    channelCacheLock.lock();
                    try {
                        // store mapping info into channelIdToChannelName / channelNameToChannelId
                        event.getChannelName().map(String::toLowerCase).filter(currentChannels::contains).ifPresent(name -> {
                            String oldName = channelIdToChannelName.put(event.getChannelId(), name);
                            if (!name.equals(oldName)) {
                                if (oldName != null) channelNameToChannelId.remove(oldName, event.getChannelId());
                                channelNameToChannelId.put(name, event.getChannelId());
                            }
                        });
                    } finally {
                        channelCacheLock.unlock();
                    }
                }
            }
        });
    }

    /**
     * Connecting to IRC-WS
     */
    @Synchronized
    public void connect() {
        if (connectionState.equals(TMIConnectionState.DISCONNECTED) || connectionState.equals(TMIConnectionState.RECONNECTING)) {
            try {
                // Change Connection State
                connectionState = TMIConnectionState.CONNECTING;

                // Recreate Socket if state does not equal CREATED
                createWebSocket();

                // Connect to IRC WebSocket
                this.webSocket.connect();
            } catch (Exception ex) {
                log.error("Connection to Twitch IRC failed: Retrying ...", ex);
                // Sleep before trying to reconnect
                try {
                    backoff.sleep();
                } catch (Exception ignored) {

                } finally {
                    // reconnect
                    reconnect();
                }
            }
        }
    }

    /**
     * Disconnecting from IRC-WS
     */
    @Synchronized
    public void disconnect() {
        if (connectionState.equals(TMIConnectionState.CONNECTED)) {
            sendTextToWebSocket("QUIT", true); // safe disconnect
            connectionState = TMIConnectionState.DISCONNECTING;
        }

        connectionState = TMIConnectionState.DISCONNECTED;

        // CleanUp
        this.webSocket.clearListeners();
        this.webSocket.disconnect();
        this.webSocket = null;
    }

    /**
     * Reconnecting to IRC-WS
     */
    @Synchronized
    public void reconnect() {
        connectionState = TMIConnectionState.RECONNECTING;
        disconnect();
        connect();
    }

    /**
     * Recreate the WebSocket and the listeners
     */
    @Synchronized
    private void createWebSocket() {
        try {
            // WebSocket
            this.webSocket = webSocketFactory.createSocket(this.baseUrl);

            // WebSocket Listeners
            this.webSocket.clearListeners();
            this.webSocket.addListener(new WebSocketAdapter() {

                @Override
                public void onConnected(WebSocket ws, Map> headers) {
                    log.info("Connecting to Twitch IRC {}", baseUrl);

                    // acquire capabilities
                    sendTextToWebSocket("CAP REQ :twitch.tv/tags twitch.tv/commands" + (enableMembershipEvents ? " twitch.tv/membership" : ""), true);
                    sendTextToWebSocket("CAP END", true);

                    // sign in
                    String userName;
                    if (chatCredential != null) {
                        boolean sendRealPass = sendCredentialToThirdPartyHost // check whether this security feature has been overridden
                            || baseUrl.equalsIgnoreCase(TWITCH_WEB_SOCKET_SERVER) // check whether the url is exactly the official one
                            || baseUrl.equalsIgnoreCase(TWITCH_WEB_SOCKET_SERVER.substring(0, TWITCH_WEB_SOCKET_SERVER.length() - 4)); // check whether the url matches without the port
                        sendTextToWebSocket(String.format("pass oauth:%s", sendRealPass ? chatCredential.getAccessToken() : CryptoUtils.generateNonce(30)), true);
                        userName = chatCredential.getUserName();
                    } else {
                        userName = "justinfan" + ThreadLocalRandom.current().nextInt(100000);
                    }
                    sendTextToWebSocket(String.format("nick %s", userName), true);

                    // Join defined channels, in case we reconnect or weren't connected yet when we called joinChannel
                    for (String channel : currentChannels) {
                        sendCommand("join", '#' + channel);
                    }

                    // then join to own channel - required for sending or receiving whispers
                    if (chatCredential != null && chatCredential.getUserName() != null) {
                        if (autoJoinOwnChannel)
                            joinChannel(chatCredential.getUserName().toLowerCase());
                    } else {
                        log.warn("Chat: The whispers feature is currently not available because the provided credential does not hold information about the user. Please check the documentation on how to pass the token to the credentialManager where it will be enriched with the required information.");
                    }

                    // Connection Success
                    connectionState = TMIConnectionState.CONNECTED;
                    backoffClearer = taskExecutor.schedule(() -> {
                        if (connectionState == TMIConnectionState.CONNECTED)
                            backoff.reset();
                    }, 30, TimeUnit.SECONDS);
                }

                @Override
                public void onTextMessage(WebSocket ws, String text) {
                    Arrays.asList(text.replace("\n\r", "\n")
                        .replace("\r", "\n").split("\n"))
                        .forEach(message -> {
                            if (!message.equals("")) {
                                // Handle messages
                                log.trace("Received WebSocketMessage: " + message);
                                // - CAP
                                if (message.contains(":req Invalid CAP command")) {
                                    log.error("Failed to acquire requested IRC capabilities!");
                                }
                                else if (message.contains(":tmi.twitch.tv CAP * ACK :")) {
                                    List capabilities = Arrays.asList(message.replace(":tmi.twitch.tv CAP * ACK :", "").split(" "));
                                    capabilities.forEach(cap -> log.debug("Acquired chat capability: " + cap ));
                                }
                                // - Ping
                                else if(message.contains("PING :tmi.twitch.tv")) {
                                    sendTextToWebSocket("PONG :tmi.twitch.tv", true);
                                    log.debug("Responding to PING request!");
                                }
                                // - Login failed.
                                else if(message.equals(":tmi.twitch.tv NOTICE * :Login authentication failed")) {
                                    log.error("Invalid IRC Credentials. Login failed!");
                                }
                                // - Parse IRC Message
                                else
                                {
                                    try {
                                        IRCMessageEvent event = new IRCMessageEvent(message, channelIdToChannelName, channelNameToChannelId, botOwnerIds);

                                        if (event.isValid()) {
                                            eventManager.publish(event);
                                        } else {
                                            log.trace("Can't parse {}", event.getRawMessage());
                                        }
                                    } catch (Exception ex) {
                                        log.error(ex.getMessage(), ex);
                                    }
                                }
                            }
                        });
                }

                @Override
                public void onDisconnected(WebSocket websocket,
                                           WebSocketFrame serverCloseFrame, WebSocketFrame clientCloseFrame,
                                           boolean closedByServer) {
                    if (!connectionState.equals(TMIConnectionState.DISCONNECTING)) {
                        log.info("Connection to Twitch IRC lost (WebSocket)! Retrying soon ...");

                        // connection lost - reconnecting
                        if (backoffClearer != null) backoffClearer.cancel(false);
                        taskExecutor.schedule(() -> reconnect(), backoff.get(), TimeUnit.MILLISECONDS);
                    } else {
                        connectionState = TMIConnectionState.DISCONNECTED;
                        log.info("Disconnected from Twitch IRC (WebSocket)!");
                    }
                }

            });

        } catch (Exception ex) {
            log.error(ex.getMessage(), ex);
        }
    }

    /**
     * Send IRC Command
     *
     * @param command IRC Command
     * @param args command arguments
     */
    protected void sendCommand(String command, String... args) {
        ircCommandQueue.offer(String.format("%s %s", command.toUpperCase(), String.join(" ", args)));
    }

    /**
     * Send raw irc command
     *
     * @param command raw irc command
     */
    public void sendRaw(String command) {
        ircCommandQueue.offer(command);
    }

    /**
     * Send IRC Command (for Login/...)
     * 

* Sends important irc commands for login / capabilities and similar. * Will consume tokens to respect the ratelimit, but will bypass the limit if the bucket is empty. * * @param command IRC Command * @param consumeToken should a token be consumed when sending this text? */ private boolean sendTextToWebSocket(String command, Boolean consumeToken) { // will send text only if CONNECTED or CONNECTING if (!connectionState.equals(TMIConnectionState.CONNECTED) && !connectionState.equals(TMIConnectionState.CONNECTING)) { return false; } // consume tokens if available, but ignore if not as those are important system commands (CAP, Login, ...) if (consumeToken) ircMessageBucket.tryConsume(1L); // command will be uppercase. this.webSocket.sendText(command); return true; } /** * Joining the channel * @param channelName channel name */ @Override public void joinChannel(String channelName) { String lowerChannelName = channelName.toLowerCase(); channelCacheLock.lock(); try { if (currentChannels.add(lowerChannelName)) { sendCommand("join", "#" + lowerChannelName); log.debug("Joining Channel [{}].", lowerChannelName); } else { log.warn("Already joined channel {}", channelName); } } finally { channelCacheLock.unlock(); } } /** * leaving the channel * @param channelName channel name */ @Override public boolean leaveChannel(String channelName) { String lowerChannelName = channelName.toLowerCase(); channelCacheLock.lock(); try { if (currentChannels.remove(lowerChannelName)) { sendCommand("part", "#" + lowerChannelName); log.debug("Leaving Channel [{}].", lowerChannelName); // clear cache String cachedId = channelNameToChannelId.remove(lowerChannelName); if (cachedId != null) channelIdToChannelName.remove(cachedId); return true; } else { log.warn("Already left channel {}", channelName); return false; } } finally { channelCacheLock.unlock(); } } /** * Sending message to the joined channel * @param channel channel name * @param message message */ @Override public boolean sendMessage(String channel, String message) { return this.sendMessage(channel, message, null); } /** * Sends a message to the channel while including an optional nonce and/or reply parent. * * @param channel the name of the channel to send the message to. * @param message the message to be sent. * @param nonce the cryptographic nonce (optional). * @param replyMsgId the msgId of the parent message being replied to (optional). */ @Unofficial public boolean sendMessage(String channel, String message, String nonce, String replyMsgId) { final Map tags = new LinkedHashMap<>(); // maintain insertion order if (nonce != null) tags.put(IRCMessageEvent.NONCE_TAG_NAME, nonce); if (replyMsgId != null) tags.put(ChatReply.REPLY_MSG_ID_TAG_NAME, replyMsgId); return this.sendMessage(channel, message, tags); } /** * Sends a message to the channel while including the specified message tags. * * @param channel the name of the channel to send the message to. * @param message the message to be sent. * @param tags the message tags (unofficial). */ public boolean sendMessage(String channel, String message, @Unofficial Map tags) { StringBuilder sb = new StringBuilder(); if (tags != null && !tags.isEmpty()) { sb.append('@'); tags.forEach((k, v) -> sb.append(k).append('=').append(EscapeUtils.escapeTagValue(v)).append(';')); sb.setCharAt(sb.length() - 1, ' '); // replace last semi-colon with space } sb.append("PRIVMSG #").append(channel.toLowerCase()).append(" :").append(message); log.debug("Adding message for channel [{}] with content [{}] to the queue.", channel.toLowerCase(), message); return ircCommandQueue.offer(sb.toString()); } /** * Sends a user a private message * * @param targetUser username * @param message message */ public void sendPrivateMessage(String targetUser, String message) { log.debug("Adding private message for user [{}] with content [{}] to the queue.", targetUser, message); whisperCommandQueue.offer(String.format("PRIVMSG #%s :/w %s %s", chatCredential.getUserName().toLowerCase(), targetUser, message)); } /** * On Channel Message * * @param event ChannelMessageEvent */ private void onChannelMessage(ChannelMessageEvent event) { Optional prefix = Optional.empty(); Optional commandWithoutPrefix = Optional.empty(); // try to find a `command` based on the prefix for (String commandPrefix : this.commandPrefixes) { if (event.getMessage().startsWith(commandPrefix)) { prefix = Optional.of(commandPrefix); commandWithoutPrefix = Optional.of(event.getMessage().substring(commandPrefix.length())); break; } } // is command? if (commandWithoutPrefix.isPresent()) { log.debug("Detected a command in channel {} with content: {}", event.getChannel().getName(), commandWithoutPrefix.get()); // dispatch command event eventManager.publish(new CommandEvent(CommandSource.CHANNEL, event.getChannel().getName(), event.getUser(), prefix.get(), commandWithoutPrefix.get(), event.getPermissions())); } } /** * Close */ @Override public void close() { this.stopQueueThread = true; queueThread.cancel(false); this.disconnect(); } @Override public boolean isChannelJoined(String channelName) { return currentChannels.contains(channelName.toLowerCase()); } /** * Returns a set of all currently joined channels (without # prefix) * * @return a set of channel names * @deprecated use getChannels() instead */ @Deprecated public List getCurrentChannels() { return Collections.unmodifiableList(new ArrayList<>(currentChannels)); } @Override public Set getChannels() { return Collections.unmodifiableSet(currentChannels); } /** * @return the cached map used for channel id to name mapping */ public Map getChannelIdToChannelName() { return Collections.unmodifiableMap(channelIdToChannelName); } /** * @return the cached map sed for channel name to id mapping */ public Map getChannelNameToChannelId() { return Collections.unmodifiableMap(channelNameToChannelId); } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy