Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support crowd chant chat and pubsub experiment #346

Merged
merged 2 commits into from
May 31, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
25 changes: 23 additions & 2 deletions chat/src/main/java/com/github/twitch4j/chat/ITwitchChat.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
import com.github.philippheuer.events4j.core.EventManager;
import com.github.twitch4j.chat.events.channel.IRCMessageEvent;
import com.github.twitch4j.common.annotation.Unofficial;
import com.github.twitch4j.common.util.ChatReply;
import org.jetbrains.annotations.Nullable;

import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

Expand Down Expand Up @@ -35,7 +38,9 @@ public interface ITwitchChat extends AutoCloseable {
* @param message The message to be sent.
* @return whether the message was added to the queue
*/
boolean sendMessage(String channel, String message);
default 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.
Expand All @@ -47,7 +52,23 @@ public interface ITwitchChat extends AutoCloseable {
* @return whether the message was added to the queue
*/
@Unofficial
boolean sendMessage(String channel, String message, String nonce, String replyMsgId);
default boolean sendMessage(String channel, String message, String nonce, String replyMsgId) {
final Map<String, Object> 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).
* @return whether the message was added to the queue
*/
@Unofficial
boolean sendMessage(String channel, String message, @Unofficial @Nullable Map<String, Object> tags);

/**
* Returns a set of all currently joined channels (without # prefix)
Expand Down
26 changes: 0 additions & 26 deletions chat/src/main/java/com/github/twitch4j/chat/TwitchChat.java
Original file line number Diff line number Diff line change
Expand Up @@ -665,33 +665,7 @@ private void issuePart(String channelName) {
);
}

/**
* 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);
}

@Override
@Unofficial
public boolean sendMessage(String channel, String message, String nonce, String replyMsgId) {
final Map<String, Object> 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<String, Object> tags) {
StringBuilder sb = new StringBuilder();
if (tags != null && !tags.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@

import com.github.philippheuer.credentialmanager.domain.OAuth2Credential;
import com.github.twitch4j.chat.events.channel.ChannelNoticeEvent;
import com.github.twitch4j.chat.events.channel.IRCMessageEvent;
import com.github.twitch4j.common.annotation.Unofficial;
import com.github.twitch4j.common.pool.TwitchModuleConnectionPool;
import com.github.twitch4j.common.util.ChatReply;
import lombok.Builder;
import lombok.NonNull;
import lombok.experimental.SuperBuilder;
import org.apache.commons.lang3.RandomStringUtils;
import org.jetbrains.annotations.Nullable;

import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
Expand Down Expand Up @@ -60,56 +64,51 @@ public class TwitchChatConnectionPool extends TwitchModuleConnectionPool<TwitchC
@Builder.Default
protected final boolean automaticallyPartOnBan = false;

/**
* Sends the specified message to the channel, if it has been subscribed to.
*
* @param channel the channel to send the message to
* @param message the message to send
* @return whether a {@link TwitchChat} instance subscribed to that channel was identified and used
*/
@Override
public boolean sendMessage(final String channel, final String message) {
return this.sendMessage(channel, channel, message);
}

/**
* Sends the specified message to the channel, if it has been subscribed to, with the specified nonce 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).
* @return whether a {@link TwitchChat} instance subscribed to that channel was identified and used
*/
@Override
public boolean sendMessage(String channel, String message, String nonce, String replyMsgId) {
return this.sendMessage(channel, channel, message, nonce, replyMsgId);
public boolean sendMessage(String channel, String message, @Unofficial @Nullable Map<String, Object> tags) {
return this.sendMessage(channel, channel, message, tags);
}

/**
* Sends a message from the {@link TwitchChat} identified either to a channel or directly on the socket.
*
* @param channelToIdentifyChatInstance the channel used to identify which {@link TwitchChat} instance should be used to send the message; the instance must be subscribed to this channel.
* @param targetChannel the channel to send the message to, if not null (otherwise it is sent directly on the socket)
* @param message the message to be sent
* @param targetChannel the channel to send the message to, if not null (otherwise it is sent directly on the socket).
* @param message the message to be sent.
* @return whether a {@link TwitchChat} instance was found and used to send the message
*/
public boolean sendMessage(final String channelToIdentifyChatInstance, final String targetChannel, final String message) {
return this.sendMessage(channelToIdentifyChatInstance, targetChannel, message, null, null);
return this.sendMessage(channelToIdentifyChatInstance, targetChannel, message, Collections.emptyMap());
}

/**
* Sends a message from the identified {@link TwitchChat} instance with an optional nonce or reply parent.
*
* @param channelToIdentifyChatInstance the channel used to identify which {@link TwitchChat} instance should be used to send the message; the instance must be subscribed to this channel.
* @param targetChannel the channel to send the message to, if not null (otherwise it is sent directly on the socket)
* @param message the message to be sent
* @param targetChannel the channel to send the message to, if not null (otherwise it is sent directly on the socket).
* @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).
* @return whether a {@link TwitchChat} instance was found and used to send the message
*/
@Unofficial
public boolean sendMessage(final String channelToIdentifyChatInstance, final String targetChannel, final String message, final String nonce, final String replyMsgId) {
final Map<String, Object> tags = new LinkedHashMap<>();
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(channelToIdentifyChatInstance, targetChannel, message, tags);
}

/**
* Sends a message from the identified {@link TwitchChat} instance with the specified tags.
*
* @param channelToIdentifyChatInstance the channel used to identify which {@link TwitchChat} instance should be used to send the message; the instance must be subscribed to this channel.
* @param targetChannel the channel to send the message to, if not null (otherwise it is sent directly on the socket).
* @param message the message to be sent.
* @param tags the message tags (unofficial).
* @return whether a {@link TwitchChat} instance was found and used to send the message
*/
public boolean sendMessage(String channelToIdentifyChatInstance, String targetChannel, String message, @Unofficial @Nullable Map<String, Object> tags) {
if (channelToIdentifyChatInstance == null)
return false;

Expand All @@ -118,10 +117,7 @@ public boolean sendMessage(final String channelToIdentifyChatInstance, final Str
return false;

if (targetChannel != null) {
if (nonce == null && replyMsgId == null)
chat.sendMessage(targetChannel, message);
else
chat.sendMessage(targetChannel, message, nonce, replyMsgId);
chat.sendMessage(targetChannel, message, tags);
} else {
chat.sendRaw(message);
}
Expand All @@ -133,9 +129,9 @@ public boolean sendMessage(final String channelToIdentifyChatInstance, final Str
* Sends a whisper.
*
* @param channelToIdentifyChatInstance the channel used to identify which {@link TwitchChat} instance should be used to send the message; the instance must be subscribed to this channel.
* @param toChannel the channel to send the whisper to
* @param message the message to send in the whisper
* @return whether a {@link TwitchChat} instance was identified to send the message from
* @param toChannel the channel to send the whisper to.
* @param message the message to send in the whisper.
* @return whether a {@link TwitchChat} instance was identified to send the message from.
* @throws NullPointerException if the identified {@link TwitchChat} does not have a valid chatCredential
*/
public boolean sendPrivateMessage(final String channelToIdentifyChatInstance, final String toChannel, final String message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.github.twitch4j.chat.events.AbstractChannelEvent;
import com.github.twitch4j.chat.flag.AutoModFlag;
import com.github.twitch4j.chat.util.ChatCrowdChant;
import com.github.twitch4j.common.annotation.Unofficial;
import com.github.twitch4j.common.enums.CommandPermission;
import com.github.twitch4j.common.events.domain.EventChannel;
Expand All @@ -10,6 +11,7 @@
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Value;
import org.jetbrains.annotations.Nullable;

import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -63,10 +65,19 @@ public class ChannelMessageEvent extends AbstractChannelEvent {
/**
* Information regarding the parent message being replied to, if applicable.
*/
@Nullable
@Unofficial
@Getter(lazy = true)
private ChatReply replyInfo = ChatReply.parse(getMessageEvent().getTags());

/**
* Information regarding any associated Crowd Chant for this message, if applicable.
*/
@Nullable
@Unofficial
@Getter(lazy = true)
ChatCrowdChant chantInfo = ChatCrowdChant.parse(getMessageEvent());

/**
* Event Constructor
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.github.twitch4j.chat.util;

import com.github.twitch4j.chat.ITwitchChat;
import com.github.twitch4j.chat.events.channel.IRCMessageEvent;
import com.github.twitch4j.common.annotation.Unofficial;
import com.github.twitch4j.common.util.CryptoUtils;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Value;
import org.jetbrains.annotations.Nullable;

import java.util.LinkedHashMap;
import java.util.Map;

import static com.github.twitch4j.chat.events.channel.IRCMessageEvent.NONCE_TAG_NAME;

/**
* Information regarding a Crowd Chant participation or initiation.
*
* @see <a href="https://twitch.uservoice.com/forums/310201-chat/suggestions/43451310--test-crowd-chant">Related Uservoice</a>
*/
@Value
@Unofficial
public class ChatCrowdChant {

public static final String CHANT_MSG_ID_TAG_NAME = "crowd-chant-parent-msg-id";

/**
* The id for the parent message in the Crowd Chant.
*/
String messageId;

/**
* The text being chanted.
*/
String text;

/**
* Whether this message initiated a crowd chat.
* When false, the message is simply participating in the chant, rather than initiating.
*/
boolean initiator;

/**
* The name of the channel where the Crowd Chant took place.
*/
@Getter(AccessLevel.PRIVATE)
String channelName;

/**
* Sends the same message in the same channel to participate in the Crowd Chant, with the proper chat tag.
*
* @param chat an authenticated TwitchChat instance.
*/
@Unofficial
public void participate(ITwitchChat chat) {
Map<String, Object> tags = new LinkedHashMap<>();
tags.put(NONCE_TAG_NAME, CryptoUtils.generateNonce(32));
tags.put(CHANT_MSG_ID_TAG_NAME, getMessageId());

chat.sendMessage(channelName, text, tags);
}

/**
* Attempts to parse the {@link ChatCrowdChant} information from a chat event.
*
* @param event the raw IRCMessageEvent.
* @return ChatCrowdChant (or null if parsing was unsuccessful)
*/
@Nullable
public static ChatCrowdChant parse(IRCMessageEvent event) {
String channelName = event.getChannelName().orElse(null);
if (channelName == null) return null;

String message = event.getMessage().orElse(null);
if (message == null) return null;

if ("crowd-chant".equals(event.getTags().get("msg-id"))) {
return event.getMessageId()
.map(id -> new ChatCrowdChant(id, message, true, channelName))
.orElse(null);
}

return event.getTagValue(CHANT_MSG_ID_TAG_NAME)
.map(id -> new ChatCrowdChant(id, message, false, channelName))
.orElse(null);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ default PubSubSubscription listenForCommunityBoostEvents(OAuth2Credential creden
return listenOnTopic(PubSubType.LISTEN, credential, "community-boost-events-v1." + channelId);
}

@Unofficial
default PubSubSubscription listenForCrowdChantEvents(OAuth2Credential credential, String channelId) {
return listenOnTopic(PubSubType.LISTEN, credential, "crowd-chant-channel-v1." + channelId);
}

@Unofficial
default PubSubSubscription listenForUserChannelPointsEvents(OAuth2Credential credential, String userId) {
return listenOnTopic(PubSubType.LISTEN, credential, "community-points-user-v1." + userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,13 @@ public void onTextMessage(WebSocket ws, String text) {
break;
}

} else if (topic.startsWith("crowd-chant-channel-v1")) {
if ("crowd-chant-created".equals(type)) {
CrowdChantCreatedEvent event = TypeConvert.convertValue(msgData, CrowdChantCreatedEvent.class);
eventManager.publish(event);
} else {
log.warn("Unparsable Message: " + message.getType() + "|" + message.getData());
}
} else if (topic.startsWith("raid")) {
switch (type) {
case "raid_go_v2":
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.github.twitch4j.pubsub.domain;

import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;

import java.time.Instant;

@Data
@Setter(AccessLevel.PRIVATE)
public class CrowdChant {
private String id;
private String channelId;
private ChannelPointsUser user;
private String chatMessageId;
private String text;
private Instant createdAt;
private Instant endsAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.github.twitch4j.pubsub.events;

import com.github.twitch4j.common.events.TwitchEvent;
import com.github.twitch4j.pubsub.domain.CrowdChant;
import lombok.AccessLevel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Setter;

import java.time.Instant;

@Data
@Setter(AccessLevel.PRIVATE)
@EqualsAndHashCode(callSuper = false)
public class CrowdChantCreatedEvent extends TwitchEvent {
private Instant timestamp;
private CrowdChant crowdChant;
}