+ * ・拡張子で画像系ファイルであれば画像として扱う
+ * ・その後、MimeTypeでも判定する
+ * ・拡張子で画像系ファイルでなければファイルとして扱う。この際はファイル名を読み上げる
+ */
+public class AttachmentsProcessor implements BaseProcessor {
+ @Override
+ public ProcessorType getType() {
+ return ProcessorType.ATTACHMENTS;
+ }
+
+ @Override
+ public void execute(JDA jda, Guild guild, TextChannel channel, Member member, Message message, UserVoiceTextResult uvtr) {
+ for (Message.Attachment attachment : message.getAttachments()) {
+ // スポイラーファイルは無条件でスポイラーファイルとして読み上げ
+ if (attachment.isSpoiler()) {
+ uvtr.vt().play(TrackInfo.SpeakFromType.RECEIVED_FILE, message, "スポイラーファイルが送信されました。");
+ return;
+ }
+
+ // 画像か、それ以外で分岐する
+ if (VisionAPI.isCheckTarget(attachment.getFileExtension())) {
+ processImage(message, uvtr.vt(), attachment);
+ return;
+ }
+
+ processFile(message, uvtr.vt(), attachment);
+ }
+ }
+
+ void processImage(Message message, VoiceText vt, Message.Attachment attachment) {
+ VisionAPI visionAPI = Main.getVisionAPI();
+ if (visionAPI == null) {
+ processFile(message, vt, attachment);
+ return;
+ }
+ LibFiles.VDirectory.VISION_API_TEMP.mkdirs();
+ attachment
+ .getProxy()
+ .downloadToFile(LibFiles.VDirectory.VISION_API_TEMP.getPath().resolve(attachment.getFileName()).toFile())
+ .thenAcceptAsync(file -> {
+ try {
+ List
+ * ・スレッド作成時に自動投稿されるスレッド作成メッセージを処理する。
+ */
+public class CreatedThreadProcessor implements BaseProcessor {
+ @Override
+ public ProcessorType getType() {
+ return ProcessorType.CREATED_THREAD;
+ }
+
+ @Override
+ public void execute(JDA jda, Guild guild, TextChannel channel, Member member, Message message, UserVoiceTextResult uvtr) {
+ // 特定のメッセージからの派生としてスレッドを作成する場合は本プロセッサは動作しない可能性がある
+
+ String threadName = message.getContentRaw(); // message.getStartedThread() が必ず null になるので、暫定的にこれで代用
+
+ uvtr.vt().play(TrackInfo.SpeakFromType.CREATED_THREAD, message, "%sがスレッド「%s」を開始しました。".formatted(member.getUser().getName(), threadName));
+ }
+}
diff --git a/src/main/java/com/jaoafa/jdavcspeaker/MessageProcessor/DefaultMessageProcessor.java b/src/main/java/com/jaoafa/jdavcspeaker/MessageProcessor/DefaultMessageProcessor.java
new file mode 100644
index 0000000..7b88c78
--- /dev/null
+++ b/src/main/java/com/jaoafa/jdavcspeaker/MessageProcessor/DefaultMessageProcessor.java
@@ -0,0 +1,412 @@
+package com.jaoafa.jdavcspeaker.MessageProcessor;
+
+import com.jaoafa.jdavcspeaker.Lib.EmojiWrapper;
+import com.jaoafa.jdavcspeaker.Lib.LibIgnore;
+import com.jaoafa.jdavcspeaker.Lib.UserVoiceTextResult;
+import com.jaoafa.jdavcspeaker.Lib.VoiceText;
+import com.jaoafa.jdavcspeaker.Main;
+import com.jaoafa.jdavcspeaker.Player.TrackInfo;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.entities.*;
+import net.htmlparser.jericho.Source;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.jetbrains.annotations.Nullable;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 「ユーザーが送信したデフォルトメッセージ」のメッセージプロセッサ
+ *
+ * ・メッセージ本文自体を原則そのまま読み上げる。(エイリアス等は適用する)
+ * ・画像など添付ファイルは ProcessorType.ATTACHMENTS で処理する。
+ */
+public class DefaultMessageProcessor implements BaseProcessor {
+ final Pattern urlPattern = Pattern.compile("https?://\\S+", Pattern.CASE_INSENSITIVE);
+ final Pattern messageUrlPattern = Pattern.compile("^https://.*?discord(?:app)?\\.com/channels/(\\d+)/(\\d+)/(\\d+)\\??(.*)$", Pattern.CASE_INSENSITIVE);
+ final Pattern eventDirectLinkUrlPattern = Pattern.compile("^(?:https?://)?(?:www\\.)?discord(?:app)?\\.com/events/(\\d+)/(\\d+)$", Pattern.CASE_INSENSITIVE);
+ final Pattern eventInviteLinkUrlPattern = Pattern.compile("^(?:https?://)?(?:www\\.)?(?:discord(?:app)?\\.com/invite|discord\\.gg)/(\\w+)\\?event=(\\d+)$", Pattern.CASE_INSENSITIVE);
+ final Pattern inviteLinkUrlPattern = Pattern.compile("^(?:https?://)?(?:www\\.)?(?:discord(?:app)?\\.com/invite|discord\\.gg)/(\\w+)$", Pattern.CASE_INSENSITIVE);
+ final Pattern tweetUrlPattern = Pattern.compile("^https://twitter\\.com/(\\w){1,15}/status/(\\d+)\\??(.*)$", Pattern.CASE_INSENSITIVE);
+ final Pattern titlePattern = Pattern.compile("
+ * VCSpeakerが参加しているテキストチャンネルとスレッドに対応しますが、アーカイブされているスレッドには対応していません。
+ *
+ * @param jda JDA
+ * @param channelId チャンネルID (or スレッドID)
+ *
+ * @return チャンネル、見つからなければnull
+ */
+ MessageChannel getChannelOrThread(JDA jda, String channelId) {
+ TextChannel textChannel = jda.getTextChannelById(channelId);
+ if (textChannel != null) {
+ return textChannel;
+ }
+ return jda.getThreadChannelById(channelId);
+ }
+
+ /**
+ * チャンネルIDをもとに、テキスト/ボイスチャンネルまたはスレッドを取得します
+ * VCSpeakerが参加しているテキスト/ボイスチャンネルとスレッドに対応しますが、アーカイブされているスレッドには対応していません。
+ *
+ * @param jda JDA
+ * @param channelId チャンネルID (or スレッドID)
+ *
+ * @return チャンネル、見つからなければnull
+ */
+ Channel getTextVoiceChannelOrThread(JDA jda, String channelId) {
+ TextChannel textChannel = jda.getTextChannelById(channelId);
+ if (textChannel != null) {
+ return textChannel;
+ }
+ VoiceChannel voiceChannel = jda.getVoiceChannelById(channelId);
+ if (voiceChannel != null) {
+ return voiceChannel;
+ }
+ return jda.getThreadChannelById(channelId);
+ }
+
+ String replacerLink(JDA jda, String content) {
+ Matcher m = urlPattern.matcher(content);
+ while (m.find()) {
+ String url = m.group();
+
+ // Discordメッセージリンク
+ Matcher msgUrlMatcher = messageUrlPattern.matcher(url);
+ if (msgUrlMatcher.find()) {
+ String channelId = msgUrlMatcher.group(2);
+ String messageId = msgUrlMatcher.group(3);
+
+ MessageChannel channel = getChannelOrThread(jda, channelId);
+ if (channel == null) continue;
+ Message message = channel.retrieveMessageById(messageId).complete();
+ if (message == null) continue;
+
+ channel = message.getChannel();
+ String channelName = "チャンネル「" + channel.getName() + "」";
+ if (channel instanceof ThreadChannel) {
+ channelName = "チャンネル「%s」のスレッド「%s」".formatted(((ThreadChannel) channel).getParentChannel().getName(), channel.getName());
+ }
+
+ content = content.replace(url, "%sが%sで送信したメッセージのリンク".formatted(
+ message.getAuthor().getAsTag(),
+ channelName
+ ));
+ continue;
+ }
+
+ Matcher tweetUrlMatcher = tweetUrlPattern.matcher(url);
+ if (tweetUrlMatcher.find()) {
+ String screenName = tweetUrlMatcher.group(1);
+ String tweetId = tweetUrlMatcher.group(2);
+
+ Tweet tweet = getTweet(screenName, tweetId);
+ if (tweet != null) {
+ System.out.println(tweet);
+ content = content.replace(url, "%sのツイート「%s」へのリンク".formatted(
+ EmojiWrapper.removeAllEmojis(tweet.authorName()),
+ tweet.plainText()
+ ));
+ continue;
+ }
+ }
+
+ // GIFリンク
+ if (url.endsWith(".gif")) {
+ content = content.replace(url, "GIF画像へのリンク");
+ continue;
+ }
+
+ // Webページのタイトル取得
+ String title = getTitle(url);
+ if (title != null) {
+ System.out.println("title: " + title);
+ if (title.length() >= 30) {
+ title = title.substring(0, 30) + "以下略";
+ }
+ System.out.println("title 2: " + title);
+ content = content.replace(url, "Webページ「%s」へのリンク".formatted(title));
+ } else {
+ content = content.replace(url, "Webページへのリンク");
+ }
+ }
+ return content;
+ }
+
+ String replacerChannelThreadLink(JDA jda, String content) {
+ return channelReplyPattern.matcher(content).replaceAll(result -> {
+ String channelId = result.group(1);
+ Channel channel = getTextVoiceChannelOrThread(jda, channelId);
+ if (channel == null) return "どこかのチャンネルへのリンク";
+ String channelName = "チャンネル「" + channel.getName() + "」へのリンク";
+ if (channel instanceof ThreadChannel) {
+ channelName = "チャンネル「%s」のスレッド「%s」へのリンク".formatted(((ThreadChannel) channel).getParentChannel().getName(), channel.getName());
+ }
+ return channelName;
+ });
+ }
+
+ String replacerDiscordInviteLink(JDA jda, Guild sendFromGuild, String content) {
+ content = eventDirectLinkUrlPattern.matcher(content).replaceAll(result -> {
+ String guildId = result.group(1);
+ String eventId = result.group(2);
+
+ Guild guild = jda.getGuildById(guildId);
+ if (guild == null) return "どこかのサーバのイベントへのリンク";
+
+ String eventName = getScheduledEventName(guildId, eventId);
+ if (eventName == null) return "サーバ「%s」のイベントへのリンク".formatted(guild.getName());
+
+ if (guild.getIdLong() == sendFromGuild.getIdLong()) {
+ return "イベント「%s」へのリンク".formatted(eventName);
+ }
+ return "サーバ「%s」のイベント「%s」へのリンク".formatted(guild.getName(), eventName);
+ });
+
+ content = eventInviteLinkUrlPattern.matcher(content).replaceAll(result -> {
+ String inviteCode = result.group(1);
+ String eventId = result.group(2);
+
+ DiscordInvite invite = getInvite(inviteCode, eventId);
+ if (invite == null) return "どこかのサーバのイベントへのリンク";
+ if (invite.eventName() == null) return "サーバ「%s」のイベントへのリンク".formatted(invite.guildName());
+
+ if (invite.guildId().equals(sendFromGuild.getId())) {
+ return "イベント「%s」へのリンク".formatted(invite.eventName());
+ }
+ return "サーバ「%s」のイベント「%s」へのリンク".formatted(invite.guildName(), invite.eventName());
+ });
+
+ content = inviteLinkUrlPattern.matcher(content).replaceAll(result -> {
+ String inviteCode = result.group(1);
+
+ DiscordInvite invite = getInvite(inviteCode, null);
+ if (invite == null) return "どこかのサーバへの招待リンク";
+ if (invite.channelName() == null) return "サーバ「%s」への招待リンク".formatted(invite.guildName());
+
+ if (invite.guildId().equals(sendFromGuild.getId())) {
+ return "チャンネル「%s」への招待リンク".formatted(invite.channelName());
+ }
+ return "サーバ「%s」のチャンネル「%s」への招待リンク".formatted(invite.guildName(), invite.channelName());
+ });
+
+ return content;
+ }
+
+ String replacerSpoiler(String content) {
+ return spoilerPattern.matcher(content).replaceAll(" ピー ");
+ }
+
+ boolean isEmphasizeMessage(String content) {
+ return
+ content.matches("^\\*\\*(.[ ]){2,}.\\*\\*$") || // **あ い う え お** OR **あ い う え お**
+ content.matches("^(.[ ]){2,}.$") || // あ い う え お OR あ い う え お
+ content.matches("^\\*\\*(.+)\\*\\*$"); // **あああああああ**
+ }
+
+ String replacerEmphasizeMessage(String content) {
+ for (String s : Arrays.asList("**", " ", " ")) {
+ content = content.replace(s, "");
+ }
+ return content;
+ }
+
+ VoiceText changeEmphasizeSpeed(VoiceText vt) {
+ try {
+ return vt.setSpeed(Math.max(vt.getSpeed() / 2, 50));
+ } catch (VoiceText.WrongSpeedException e) {
+ return vt;
+ }
+ }
+
+ @Nullable
+ String getTitle(String url) {
+ try {
+ OkHttpClient client = new OkHttpClient().newBuilder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .build();
+ Request request = new Request.Builder().url(url).build();
+ try (Response response = client.newCall(request).execute()) {
+ if (response.code() != 200 && response.code() != 302) {
+ return null;
+ }
+ ResponseBody body = response.body();
+ if (body == null) {
+ return null;
+ }
+ Matcher m = titlePattern.matcher(body.string());
+ return m.find() ? m.group(1) : null;
+ }
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ record Tweet(String authorName, String html, String plainText) {
+ }
+
+ @Nullable
+ Tweet getTweet(String screenName, String tweetId) {
+ String url = "https://publish.twitter.com/oembed?url=https://twitter.com/%s/status/%s".formatted(screenName, tweetId);
+ try {
+ OkHttpClient client = new OkHttpClient().newBuilder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .build();
+ Request request = new Request.Builder().url(url).build();
+ try (Response response = client.newCall(request).execute()) {
+ if (response.code() != 200 && response.code() != 302) {
+ return null;
+ }
+ ResponseBody body = response.body();
+ if (body == null) {
+ return null;
+ }
+ JSONObject json = new JSONObject(body.string());
+ String html = json.getString("html");
+ String authorName = json.getString("author_name");
+ String plainText = new Source(html.replaceAll("
+ * ・「スタンプ XXX が送信されました。」と読み上げる + */ +public class StickersProcessor implements BaseProcessor { + @Override + public ProcessorType getType() { + return ProcessorType.STICKERS; + } + + @Override + public void execute(JDA jda, Guild guild, TextChannel channel, Member member, Message message, UserVoiceTextResult uvtr) { + for (MessageSticker sticker : message.getStickers()) { + uvtr.vt().play(TrackInfo.SpeakFromType.RECEIVED_STICKER, message, "スタンプ「%s」が送信されました。".formatted(sticker.getName())); + } + } +} diff --git a/src/main/java/com/jaoafa/jdavcspeaker/Player/TrackInfo.java b/src/main/java/com/jaoafa/jdavcspeaker/Player/TrackInfo.java index dfa5d05..0038518 100644 --- a/src/main/java/com/jaoafa/jdavcspeaker/Player/TrackInfo.java +++ b/src/main/java/com/jaoafa/jdavcspeaker/Player/TrackInfo.java @@ -36,6 +36,8 @@ public enum SpeakFromType { RECEIVED_FILE, /** 画像が送信された */ RECEIVED_IMAGE, + /** スタンプが送信された */ + RECEIVED_STICKER, /** VCにユーザーが参加した */ JOINED_VC, /** VCでユーザーが移動した */ @@ -47,6 +49,8 @@ public enum SpeakFromType { /** GoLiveを終了した */ ENDED_GOLIVE, /** VCタイトルを変えた */ - CHANGED_TITLE + CHANGED_TITLE, + /** スレッドを作成した */ + CREATED_THREAD } }