Spring Boot を利用した企業WeChatグループボット通知の実装

Spring Bootアプリケーションから企業WeChat(WeChat Work)のグループボットへメッセージを送信する機能は、システム監視、アラート通知、レポート配信など、さまざまな自動化シナリオで役立ちます。本記事では、この通知機能を実装するためのJavaクラス構造とHTTPクライアントの実装について解説します。

メッセージ送信APIインターフェース

まず、企業WeChatボットとの連携に必要な基本的な操作を定義するインターフェースを設計します。これには、各種メッセージの送信とファイルのアップロード機能が含まれます。

import java.io.File;

/**
 * 企業WeChatボットとのAPI通信インターフェース
 */
public interface WeChatBotApiClient {

    /**
     * 指定されたペイロードに基づいてメッセージを送信します。
     * @param botKey ボットのユニークな識別子
     * @param payload 送信するメッセージのデータ構造
     * @return APIからの応答
     */
    ApiResponse sendMessage(String botKey, MessagePayload payload);

    /**
     * JSON形式のメッセージ文字列を直接送信します。
     * @param botKey ボットのユニークな識別子
     * @param jsonMessage 送信するメッセージのJSON文字列
     * @return APIからの応答
     */
    ApiResponse sendMessage(String botKey, String jsonMessage);

    /**
     * ファイルまたは音声をアップロードします。
     * ファイルはmultipart/form-data形式でPOSTされます。
     *
     * すべてのファイルタイプは5バイト以上である必要があります。
     * 通常ファイル(FILE): サイズは20MB以下
     * 音声ファイル(VOICE): サイズは2MB以下、再生時間は60秒以下、AMR形式のみサポート
     *
     * @param botKey ボットのユニークな識別子
     * @param mediaType ファイルの種類 ("file" または "voice")
     * @param file アップロードするファイル
     * @return アップロードAPIからの応答
     */
    MediaUploadResponse uploadMedia(String botKey, String mediaType, File file);

}

メッセージペイロードの基本構造

企業WeChatボットに送信するメッセージは、特定の形式に従う必要があります。すべてのメッセージペイロードの基底クラスとして、メッセージタイプを保持する抽象クラスを定義します。

import java.util.Objects;

/**
 * すべてのメッセージペイロードの基底クラス
 */
public abstract class MessagePayload {

    private final String messageType;

    protected MessagePayload(String type) {
        this.messageType = type;
    }

    public String getMessageType() {
        return messageType;
    }

    @Override
    public String toString() {
        return "MessagePayload(messageType=" + this.getMessageType() + ")";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        MessagePayload that = (MessagePayload) o;
        return Objects.equals(messageType, that.messageType);
    }

    @Override
    public int hashCode() {
        return Objects.hash(messageType);
    }
}

特定のメッセージタイプ用ペイロード

さまざまなメッセージタイプ(ファイル、画像、Markdown、ニュース、テンプレートカード、テキスト、音声)に対応するペイロードクラスは、それぞれMessagePayloadを継承し、特定のデータ構造をカプセル化します。

ファイルメッセージ

import java.util.Objects;

/**
 * ファイルタイプメッセージのデータ構造
 */
public class FileMessage extends MessagePayload {

    private final FileContent file;

    private FileMessage(FileContent fileData) {
        super("file");
        this.file = fileData;
    }

    public FileContent getFile() {
        return file;
    }

    @Override
    public String toString() {
        return "FileMessage{" +
                "file=" + file +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        FileMessage that = (FileMessage) o;
        return Objects.equals(file, that.file);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), file);
    }

    /**
     * ファイルメッセージを構築します。
     * @param mediaId ファイルのメディアID (アップロードAPIで取得)
     */
    public static FileMessage of(String mediaId) {
        if (mediaId == null || mediaId.trim().isEmpty()) {
            throw new IllegalArgumentException("メディアIDは空であってはなりません");
        }
        return new FileMessage(new FileContent(mediaId));
    }

    /**
     * ファイルメッセージの具体的な内容
     */
    public static class FileContent {
        /**
         * ファイルのメディアID
         */
        private String media_id;

        public FileContent(String mediaId) {
            this.media_id = mediaId;
        }

        public String getMedia_id() {
            return media_id;
        }

        public void setMedia_id(String mediaId) {
            this.media_id = mediaId;
        }

        @Override
        public String toString() {
            return "FileContent{" +
                    "media_id='" + media_id + '\'' +
                    '}';
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            FileContent that = (FileContent) o;
            return Objects.equals(media_id, that.media_id);
        }

        @Override
        public int hashCode() {
            return Objects.hash(media_id);
        }
    }
}

画像メッセージ

import java.util.Objects;

/**
 * 画像タイプメッセージのデータ構造
 */
public class ImageMessage extends MessagePayload {

    private final ImageData image;

    private ImageMessage(ImageData imgData) {
        super("image");
        this.image = imgData;
    }

    public ImageData getImage() {
        return image;
    }

    @Override
    public String toString() {
        return "ImageMessage{" +
                "image=" + image +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        ImageMessage that = (ImageMessage) o;
        return Objects.equals(image, that.image);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), image);
    }

    /**
     * 画像メッセージを構築します。
     * @param base64Content 画像コンテンツのBase64エンコード文字列
     * @param md5Hash Base64エンコード前の画像コンテンツのMD5ハッシュ値
     */
    public static ImageMessage of(String base64Content, String md5Hash) {
        return new ImageMessage(new ImageData(base64Content, md5Hash));
    }

    /**
     * 画像メッセージの具体的な内容
     */
    public static class ImageData {
        /**
         * 画像コンテンツのBase64エンコード文字列
         */
        private String base64;

        /**
         * Base64エンコード前の画像コンテンツのMD5ハッシュ値
         */
        private String md5;

        public ImageData(String base64, String md5) {
            this.base64 = base64;
            this.md5 = md5;
        }

        public String getBase64() {
            return base64;
        }

        public void setBase64(String base64) {
            this.base64 = base64;
        }

        public String getMd5() {
            return md5;
        }

        public void setMd5(String md5) {
            this.md5 = md5;
        }

        @Override
        public String toString() {
            return "ImageData{" +
                    "base64='" + base64 + '\'' +
                    ", md5='" + md5 + '\'' +
                    '}';
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            ImageData imageData = (ImageData) o;
            return Objects.equals(base64, imageData.base64) &&
                    Objects.equals(md5, imageData.md5);
        }

        @Override
        public int hashCode() {
            return Objects.hash(base64, md5);
        }
    }
}

Markdownメッセージ

import java.util.Objects;

public class MarkdownMessage extends MessagePayload {

    private final MarkdownContent markdown;

    private MarkdownMessage(MarkdownContent markdownData) {
        super("markdown");
        this.markdown = markdownData;
    }

    public MarkdownContent getMarkdown() {
        return markdown;
    }

    @Override
    public String toString() {
        return "MarkdownMessage{" +
                "markdown=" + markdown +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        MarkdownMessage that = (MarkdownMessage) o;
        return Objects.equals(markdown, that.markdown);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), markdown);
    }

    /**
     * Markdownメッセージを構築します。
     * @param content Markdownコンテンツ (最大4096バイト、UTF-8エンコード)
     */
    public static MarkdownMessage of(String content) {
        return new MarkdownMessage(new MarkdownContent(content));
    }

    /**
     * Markdownメッセージの具体的な内容
     */
    public static class MarkdownContent {
        /**
         * Markdown内容。標準Markdown構文と一部HTMLタグをサポート。
         * サポートされる色タグ: <font color="warning">、<font color="comment">、<font color="info"> など
         */
        private String content;

        public MarkdownContent(String content) {
            this.content = content;
        }

        public String getContent() {
            return content;
        }

        public void setContent(String content) {
            this.content = content;
        }

        @Override
        public String toString() {
            return "MarkdownContent{" +
                    "content='" + content + '\'' +
                    '}';
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            MarkdownContent that = (MarkdownContent) o;
            return Objects.equals(content, that.content);
        }

        @Override
        public int hashCode() {
            return Objects.hash(content);
        }
    }
}

ニュース記事メッセージ(複数の記事をまとめたもの)

import java.util.List;
import java.util.Objects;

/**
 * ニュース記事タイプメッセージのデータ構造
 */
public class NewsArticleMessage extends MessagePayload {
    /**
     * ニュース記事のリスト (1〜8件までサポート)
     */
    private final ArticleList news;

    private NewsArticleMessage(ArticleList newsList) {
        super("news");
        this.news = newsList;
    }

    public ArticleList getNews() {
        return news;
    }

    @Override
    public String toString() {
        return "NewsArticleMessage{" +
                "news=" + news +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        NewsArticleMessage that = (NewsArticleMessage) o;
        return Objects.equals(news, that.news);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), news);
    }

    /**
     * ニュース記事メッセージを構築します。
     * @param articles ニュース記事のリスト (1〜8件)
     */
    public static NewsArticleMessage of(List<NewsEntry> articles) {
        return new NewsArticleMessage(new ArticleList(articles));
    }

    /**
     * ニュース記事メッセージの具体的な内容
     */
    public static class ArticleList {

        private List<NewsEntry> articles;

        public ArticleList(List<NewsEntry> articles) {
            this.articles = articles;
        }

        public List<NewsEntry> getArticles() {
            return articles;
        }

        public void setArticles(List<NewsEntry> articles) {
            this.articles = articles;
        }

        @Override
        public String toString() {
            return "ArticleList{" +
                    "articles=" + articles +
                    '}';
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            ArticleList that = (ArticleList) o;
            return Objects.equals(articles, that.articles);
        }

        @Override
        public int hashCode() {
            return Objects.hash(articles);
        }
    }

    /**
     * 個々のニュース記事エントリー
     */
    public static class NewsEntry {
        /**
         * タイトル (最大128バイト)
         */
        private String title;

        /**
         * 説明 (最大512バイト、オプション)
         */
        private String description;

        /**
         * クリック時のリンク先URL
         */
        private String url;

        /**
         * 画像のURL (オプション)
         * JPG/PNG形式をサポート。推奨サイズ: 大画像1068 * 455、小画像150 * 150
         */
        private String imageUrl;

        public String getTitle() {
            return title;
        }

        public void setTitle(String title) {
            this.title = title;
        }

        public String getDescription() {
            return description;
        }

        public void setDescription(String description) {
            this.description = description;
        }

        public String getUrl() {
            return url;
        }

        public void setUrl(String url) {
            this.url = url;
        }

        public String getImageUrl() {
            return imageUrl;
        }

        public void setImageUrl(String imageUrl) {
            this.imageUrl = imageUrl;
        }

        @Override
        public String toString() {
            return "NewsEntry{" +
                    "title='" + title + '\'' +
                    ", description='" + description + '\'' +
                    ", url='" + url + '\'' +
                    ", imageUrl='" + imageUrl + '\'' +
                    '}';
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            NewsEntry newsEntry = (NewsEntry) o;
            return Objects.equals(title, newsEntry.title) &&
                    Objects.equals(description, newsEntry.description) &&
                    Objects.equals(url, newsEntry.url) &&
                    Objects.equals(imageUrl, newsEntry.imageUrl);
        }

        @Override
        public int hashCode() {
            return Objects.hash(title, description, url, imageUrl);
        }
    }
}

API応答の共通構造

すべてのAPI呼び出しに対する共通の応答構造を定義します。これにより、エラー処理を一元化できます。

import java.util.Objects;

public class ApiResponse {

    /**
     * エラーコード
     */
    private Integer errorCode;

    /**
     * エラーメッセージ
     */
    private String errorMessage;

    /**
     * 成功応答かどうかを判定します。
     */
    public boolean isSuccessful() {
        return errorCode != null && errorCode == 0;
    }

    /**
     * エラー応答かどうかを判定します。
     */
    public boolean isError() {
        return errorCode != null && errorCode != 0;
    }
    
    public Integer getErrorCode() {
        return errorCode;
    }

    public void setErrorCode(Integer errorCode) {
        this.errorCode = errorCode;
    }

    public String getErrorMessage() {
        return errorMessage;
    }

    public void setErrorMessage(String errorMessage) {
        this.errorMessage = errorMessage;
    }

    @Override
    public String toString() {
        return "ApiResponse{" +
                "errorCode=" + errorCode +
                ", errorMessage='" + errorMessage + '\'' +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ApiResponse that = (ApiResponse) o;
        return Objects.equals(errorCode, that.errorCode) &&
                Objects.equals(errorMessage, that.errorMessage);
    }

    @Override
    public int hashCode() {
        return Objects.hash(errorCode, errorMessage);
    }
}

グラフィックテンプレートカードメッセージ

import java.util.Arrays;
import java.util.List;
import java.util.Objects;

/**
 * グラフィック表示テンプレートカードメッセージのデータ構造
 */
public class GraphicCardMessage extends MessagePayload {

    private final TemplateCardLayout templateCard;

    private GraphicCardMessage(TemplateCardLayout cardLayout) {
        super("template_card");
        this.templateCard = cardLayout;
    }

    public TemplateCardLayout getTemplateCard() {
        return templateCard;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        GraphicCardMessage that = (GraphicCardMessage) o;
        return Objects.equals(templateCard, that.templateCard);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), templateCard);
    }

    @Override
    public String toString() {
        return "GraphicCardMessage(super=" + super.toString() +
                ", templateCard=" + templateCard + ")";
    }

    /**
     * シンプルなグラフィックテンプレートカードメッセージを作成します。
     *
     * @param primaryTitle 主タイトル
     * @param cardPicture カード画像
     * @param cardInteraction カードクリック時の動作
     * @return メッセージペイロード
     */
    public static GraphicCardMessage createSimpleCard(PrimaryTitle primaryTitle, CardImageInfo cardPicture, CardInteraction cardInteraction) {
        return newCardBuilder()
                .withCardType("news_notice")
                .withPrimaryTitle(primaryTitle)
                .withCardImageInfo(cardPicture)
                .withCardInteraction(cardInteraction)
                .build();
    }

    /**
     * テンプレートカードのビルダーを作成します。
     */
    public static CardBuilder newCardBuilder() {
        return new CardBuilder();
    }

    /**
     * テンプレートカードメッセージのビルダー
     */
    public static class CardBuilder {
        private String cardType = "news_notice";
        private SourceInfo sourceInformation;
        private PrimaryTitle mainTitle;
        private CardImageInfo cardImage;
        private ImageTextBlock imageTextArea;
        private QuoteBlock quoteArea;
        private List<VerticalElement> verticalContentItems;
        private List<HorizontalElement> horizontalContentItems;
        private List<JumpAction> jumpActions;
        private CardInteraction cardAction;

        public CardBuilder withCardType(String type) {
            this.cardType = type;
            return this;
        }

        public CardBuilder withSourceInfo(SourceInfo info) {
            this.sourceInformation = info;
            return this;
        }

        public CardBuilder withPrimaryTitle(PrimaryTitle title) {
            this.mainTitle = title;
            return this;
        }

        public CardBuilder withCardImageInfo(CardImageInfo imageInfo) {
            this.cardImage = imageInfo;
            return this;
        }

        public CardBuilder withImageTextBlock(ImageTextBlock textBlock) {
            this.imageTextArea = textBlock;
            return this;
        }

        public CardBuilder withQuoteBlock(QuoteBlock block) {
            this.quoteArea = block;
            return this;
        }

        public CardBuilder withVerticalContentItems(List<VerticalElement> items) {
            this.verticalContentItems = items;
            return this;
        }

        public CardBuilder withHorizontalContentItems(List<HorizontalElement> items) {
            this.horizontalContentItems = items;
            return this;
        }

        public CardBuilder withJumpActions(List<JumpAction> actions) {
            this.jumpActions = actions;
            return this;
        }

        public CardBuilder withCardInteraction(CardInteraction action) {
            this.cardAction = action;
            return this;
        }

        /**
         * テンプレートカードメッセージを構築します。
         */
        public GraphicCardMessage build() {
            // 必須条件の検証
            if (mainTitle == null) {
                throw new IllegalArgumentException("主タイトル (mainTitle) は必須です。");
            }
            if (cardImage == null) {
                throw new IllegalArgumentException("カード画像 (cardImage) は必須です。");
            }
            if (cardAction == null) {
                throw new IllegalArgumentException("ニュース通知テンプレートカードではカードインタラクション (cardAction) は必須です。");
            }
            if (imageTextArea != null && imageTextArea.getImageUrl() == null) {
                throw new IllegalArgumentException("画像テキストブロック (imageTextArea) に画像URLは必須です。");
            }

            TemplateCardLayout cardLayout = new TemplateCardLayout(
                    cardType, sourceInformation, mainTitle, cardImage, imageTextArea, quoteArea,
                    verticalContentItems, horizontalContentItems, jumpActions, cardAction
            );
            return new GraphicCardMessage(cardLayout);
        }
    }

    /**
     * テンプレートカードのレイアウト定義
     */
    public static class TemplateCardLayout {
        /**
         * テンプレートカードの種類 (グラフィック表示テンプレートは "news_notice")
         */
        private final String card_type;

        /**
         * カードのソーススタイル情報 (不要な場合は省略可)
         */
        private final SourceInfo source;

        /**
         * テンプレートカードの主要コンテンツ (一次タイトルと補助情報を含む)
         */
        private final PrimaryTitle main_title;

        /**
         * 画像のスタイル
         */
        private final CardImageInfo card_image;

        /**
         * 左画像右テキストのスタイル
         */
        private final ImageTextBlock image_text_area;

        /**
         * 引用文献スタイル (キーデータとの併用は推奨されません)
         */
        private final QuoteBlock quote_area;

        /**
         * カードの二次垂直コンテンツ (空配列でも可、データがある場合は対応するフィールドの必須性を確認。リスト長は4以下)
         */
        private final List<VerticalElement> vertical_content_list;

        /**
         * 二次タイトル+テキストリスト (空配列でも可、データがある場合は対応するフィールドの必須性を確認。リスト長は6以下)
         */
        private final List<HorizontalElement> horizontal_content_list;

        /**
         * ジャンプガイドスタイルのリスト (空配列でも可、データがある場合は対応するフィールドの必須性を確認。リスト長は3以下)
         */
        private final List<JumpAction> jump_list;

        /**
         * カード全体のクリックジャンプイベント (news_noticeテンプレートカードでは必須)
         */
        private final CardInteraction card_action;

        public TemplateCardLayout(String cardType, SourceInfo source, PrimaryTitle mainTitle, CardImageInfo cardImage,
                                  ImageTextBlock imageTextArea, QuoteBlock quoteArea,
                                  List<VerticalElement> verticalContentItems,
                                  List<HorizontalElement> horizontalContentItems,
                                  List<JumpAction> jumpActions, CardInteraction cardAction) {
            this.card_type = cardType;
            this.source = source;
            this.main_title = mainTitle;
            this.card_image = cardImage;
            this.image_text_area = imageTextArea;
            this.quote_area = quoteArea;
            this.vertical_content_list = verticalContentItems;
            this.horizontal_content_list = horizontalContentItems;
            this.jump_list = jumpActions;
            this.card_action = cardAction;
        }

        public String getCard_type() { return card_type; }
        public SourceInfo getSource() { return source; }
        public PrimaryTitle getMain_title() { return main_title; }
        public CardImageInfo getCard_image() { return card_image; }
        public ImageTextBlock getImage_text_area() { return image_text_area; }
        public QuoteBlock getQuote_area() { return quote_area; }
        public List<VerticalElement> getVertical_content_list() { return vertical_content_list; }
        public List<HorizontalElement> getHorizontal_content_list() { return horizontal_content_list; }
        public List<JumpAction> getJump_list() { return jump_list; }
        public CardInteraction getCard_action() { return card_action; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            TemplateCardLayout that = (TemplateCardLayout) o;
            return Objects.equals(card_type, that.card_type) &&
                    Objects.equals(source, that.source) &&
                    Objects.equals(main_title, that.main_title) &&
                    Objects.equals(card_image, that.card_image) &&
                    Objects.equals(image_text_area, that.image_text_area) &&
                    Objects.equals(quote_area, that.quote_area) &&
                    Objects.equals(vertical_content_list, that.vertical_content_list) &&
                    Objects.equals(horizontal_content_list, that.horizontal_content_list) &&
                    Objects.equals(jump_list, that.jump_list) &&
                    Objects.equals(card_action, that.card_action);
        }

        @Override
        public int hashCode() {
            return Objects.hash(card_type, source, main_title, card_image, image_text_area,
                    quote_area, vertical_content_list, horizontal_content_list,
                    jump_list, card_action);
        }

        @Override
        public String toString() {
            return "TemplateCardLayout{" +
                    "card_type='" + card_type + '\'' +
                    ", source=" + source +
                    ", main_title=" + main_title +
                    ", card_image=" + card_image +
                    ", image_text_area=" + image_text_area +
                    ", quote_area=" + quote_area +
                    ", vertical_content_list=" + vertical_content_list +
                    ", horizontal_content_list=" + horizontal_content_list +
                    ", jump_list=" + jump_list +
                    ", card_action=" + card_action +
                    '}';
        }
    }

    /**
     * カードのソース情報
     */
    public static class SourceInfo {
        /**
         * ソース画像のURL
         */
        private final String icon_url;

        /**
         * ソース画像の記述 (13文字以内推奨)
         */
        private final String desc;

        /**
         * ソーステキストの色 (0: デフォルト(灰色), 1: 黒, 2: 赤, 3: 緑 をサポート)
         */
        private final Integer desc_color;

        private SourceInfo(String iconUrl, String description, Integer descriptionColor) {
            this.icon_url = iconUrl;
            this.desc = description;
            this.desc_color = descriptionColor;
        }

        public String getIcon_url() { return icon_url; }
        public String getDesc() { return desc; }
        public Integer getDesc_color() { return desc_color; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            SourceInfo that = (SourceInfo) o;
            return Objects.equals(icon_url, that.icon_url) &&
                    Objects.equals(desc, that.desc) &&
                    Objects.equals(desc_color, that.desc_color);
        }

        @Override
        public int hashCode() {
            return Objects.hash(icon_url, desc, desc_color);
        }

        @Override
        public String toString() {
            return "SourceInfo{" +
                    "icon_url='" + icon_url + '\'' +
                    ", desc='" + desc + '\'' +
                    ", desc_color=" + desc_color +
                    '}';
        }

        public static class Builder {
            private String iconUrl;
            private String description;
            private Integer descriptionColor = 0;

            public Builder withIconUrl(String url) {
                this.iconUrl = url;
                return this;
            }

            public Builder withDescription(String desc) {
                this.description = desc;
                return this;
            }

            public Builder withDescriptionColor(Integer color) {
                this.descriptionColor = color;
                return this;
            }

            public SourceInfo build() {
                return new SourceInfo(iconUrl, description, descriptionColor);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * テンプレートカードの主要タイトルコンテンツ
     */
    public static class PrimaryTitle {
        /**
         * 一次タイトル (26文字以内推奨)
         */
        private final String title;

        /**
         * タイトルの補助情報 (30文字以内推奨)
         */
        private final String desc;

        private PrimaryTitle(String titleText, String descriptionText) {
            this.title = titleText;
            this.desc = descriptionText;
        }

        public String getTitle() { return title; }
        public String getDesc() { return desc; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            PrimaryTitle that = (PrimaryTitle) o;
            return Objects.equals(title, that.title) &&
                    Objects.equals(desc, that.desc);
        }

        @Override
        public int hashCode() {
            return Objects.hash(title, desc);
        }

        @Override
        public String toString() {
            return "PrimaryTitle{" +
                    "title='" + title + '\'' +
                    ", desc='" + desc + '\'' +
                    '}';
        }

        public static class Builder {
            private String title;
            private String desc;

            public Builder withTitle(String titleText) {
                this.title = titleText;
                return this;
            }

            public Builder withDescription(String descriptionText) {
                this.desc = descriptionText;
                return this;
            }

            public PrimaryTitle build() {
                return new PrimaryTitle(title, desc);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * カード画像情報
     */
    public static class CardImageInfo {
        /**
         * 画像のURL
         */
        private final String url;

        /**
         * 画像のアスペクト比 (2.25未満、1.3より大きく、デフォルト1.3)
         */
        private final Float aspect_ratio;

        private CardImageInfo(String imageUrl, Float aspectRatio) {
            this.url = imageUrl;
            this.aspect_ratio = aspectRatio;
        }

        public String getUrl() { return url; }
        public Float getAspect_ratio() { return aspect_ratio; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            CardImageInfo that = (CardImageInfo) o;
            return Objects.equals(url, that.url) &&
                    Objects.equals(aspect_ratio, that.aspect_ratio);
        }

        @Override
        public int hashCode() {
            return Objects.hash(url, aspect_ratio);
        }

        @Override
        public String toString() {
            return "CardImageInfo{" +
                    "url='" + url + '\'' +
                    ", aspect_ratio=" + aspect_ratio +
                    '}';
        }

        public static class Builder {
            private String url;
            private Float aspectRatio = 1.3f;

            public Builder withUrl(String imageUrl) {
                this.url = imageUrl;
                return this;
            }

            public Builder withAspectRatio(Float ratio) {
                this.aspectRatio = ratio;
                return this;
            }

            public CardImageInfo build() {
                return new CardImageInfo(url, aspectRatio);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * 左画像右テキストブロックのスタイル
     */
    public static class ImageTextBlock {
        /**
         * クリックイベントタイプ (0: なし, 1: URLへジャンプ, 2: ミニプログラムへジャンプ)
         */
        private final Integer type;

        /**
         * クリックジャンプURL (typeが1の場合に必須)
         */
        private final String url;

        /**
         * クリックジャンプ先ミニプログラムのAppID (typeが2の場合に必須)
         */
        private final String appid;

        /**
         * クリックジャンプ先ミニプログラムのページパス (typeが2の場合にオプション)
         */
        private final String pagepath;

        /**
         * 左画像右テキストブロックのタイトル
         */
        private final String title;

        /**
         * 左画像右テキストブロックの記述
         */
        private final String desc;

        /**
         * 左画像右テキストブロックの画像URL
         */
        private final String image_url;

        private ImageTextBlock(Integer type, String url, String appId, String pagePath,
                               String titleText, String descriptionText, String imageUrl) {
            this.type = type;
            this.url = url;
            this.appid = appId;
            this.pagepath = pagePath;
            this.title = titleText;
            this.desc = descriptionText;
            this.image_url = imageUrl;
        }

        public Integer getType() { return type; }
        public String getUrl() { return url; }
        public String getAppid() { return appid; }
        public String getPagepath() { return pagepath; }
        public String getTitle() { return title; }
        public String getDesc() { return desc; }
        public String getImage_url() { return image_url; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            ImageTextBlock that = (ImageTextBlock) o;
            return Objects.equals(type, that.type) &&
                    Objects.equals(url, that.url) &&
                    Objects.equals(appid, that.appid) &&
                    Objects.equals(pagepath, that.pagepath) &&
                    Objects.equals(title, that.title) &&
                    Objects.equals(desc, that.desc) &&
                    Objects.equals(image_url, that.image_url);
        }

        @Override
        public int hashCode() {
            return Objects.hash(type, url, appid, pagepath, title, desc, image_url);
        }

        @Override
        public String toString() {
            return "ImageTextBlock{" +
                    "type=" + type +
                    ", url='" + url + '\'' +
                    ", appid='" + appid + '\'' +
                    ", pagepath='" + pagepath + '\'' +
                    ", title='" + title + '\'' +
                    ", desc='" + desc + '\'' +
                    ", image_url='" + image_url + '\'' +
                    '}';
        }

        public static class Builder {
            private Integer type = 0;
            private String url;
            private String appid;
            private String pagepath;
            private String title;
            private String desc;
            private String image_url;

            public Builder withType(Integer clickType) {
                this.type = clickType;
                return this;
            }

            public Builder withUrl(String clickUrl) {
                this.url = clickUrl;
                return this;
            }

            public Builder withAppid(String appId) {
                this.appid = appId;
                return this;
            }

            public Builder withPagepath(String pagePath) {
                this.pagepath = pagePath;
                return this;
            }

            public Builder withTitle(String titleText) {
                this.title = titleText;
                return this;
            }

            public Builder withDescription(String descriptionText) {
                this.desc = descriptionText;
                return this;
            }

            public Builder withImageUrl(String imageUrl) {
                this.image_url = imageUrl;
                return this;
            }

            public ImageTextBlock build() {
                return new ImageTextBlock(type, url, appid, pagepath, title, desc, image_url);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * 引用文献スタイルのブロック
     */
    public static class QuoteBlock {
        /**
         * クリックイベントタイプ (0: なし, 1: URLへジャンプ, 2: ミニプログラムへジャンプ)
         */
        private final Integer type;

        /**
         * クリックジャンプURL (typeが1の場合に必須)
         */
        private final String url;

        /**
         * クリックジャンプ先ミニプログラムのAppID (typeが2の場合に必須)
         */
        private final String appid;

        /**
         * クリックジャンプ先ミニプログラムのページパス (typeが2の場合にオプション)
         */
        private final String pagepath;

        /**
         * 引用文献ブロックのタイトル
         */
        private final String title;

        /**
         * 引用文献ブロックの引用テキスト
         */
        private final String quote_text;

        private QuoteBlock(Integer type, String url, String appId, String pagePath,
                           String titleText, String quoteText) {
            this.type = type;
            this.url = url;
            this.appid = appId;
            this.pagepath = pagePath;
            this.title = titleText;
            this.quote_text = quoteText;
        }

        public Integer getType() { return type; }
        public String getUrl() { return url; }
        public String getAppid() { return appid; }
        public String getPagepath() { return pagepath; }
        public String getTitle() { return title; }
        public String getQuote_text() { return quote_text; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            QuoteBlock that = (QuoteBlock) o;
            return Objects.equals(type, that.type) &&
                    Objects.equals(url, that.url) &&
                    Objects.equals(appid, that.appid) &&
                    Objects.equals(pagepath, that.pagepath) &&
                    Objects.equals(title, that.title) &&
                    Objects.equals(quote_text, that.quote_text);
        }

        @Override
        public int hashCode() {
            return Objects.hash(type, url, appid, pagepath, title, quote_text);
        }

        @Override
        public String toString() {
            return "QuoteBlock{" +
                    "type=" + type +
                    ", url='" + url + '\'' +
                    ", appid='" + appid + '\'' +
                    ", pagepath='" + pagepath + '\'' +
                    ", title='" + title + '\'' +
                    ", quote_text='" + quote_text + '\'' +
                    '}';
        }

        public static class Builder {
            private Integer type = 0;
            private String url;
            private String appid;
            private String pagepath;
            private String title;
            private String quote_text;

            public Builder withType(Integer clickType) {
                this.type = clickType;
                return this;
            }

            public Builder withUrl(String clickUrl) {
                this.url = clickUrl;
                return this;
            }

            public Builder withAppid(String appId) {
                this.appid = appId;
                return this;
            }

            public Builder withPagepath(String pagePath) {
                this.pagepath = pagePath;
                return this;
            }

            public Builder withTitle(String titleText) {
                this.title = titleText;
                return this;
            }

            public Builder withQuoteText(String quoteText) {
                this.quote_text = quoteText;
                return this;
            }

            public QuoteBlock build() {
                return new QuoteBlock(type, url, appid, pagepath, title, quote_text);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * 垂直コンテンツ要素
     */
    public static class VerticalElement {
        /**
         * カードの二次タイトル (26文字以内推奨)
         */
        private final String title;

        /**
         * 二次通常テキスト (112文字以内推奨)
         */
        private final String desc;

        private VerticalElement(String titleText, String descriptionText) {
            this.title = titleText;
            this.desc = descriptionText;
        }

        public String getTitle() { return title; }
        public String getDesc() { return desc; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            VerticalElement that = (VerticalElement) o;
            return Objects.equals(title, that.title) &&
                    Objects.equals(desc, that.desc);
        }

        @Override
        public int hashCode() {
            return Objects.hash(title, desc);
        }

        @Override
        public String toString() {
            return "VerticalElement{" +
                    "title='" + title + '\'' +
                    ", desc='" + desc + '\'' +
                    '}';
        }

        public static class Builder {
            private String title;
            private String desc;

            public Builder withTitle(String titleText) {
                this.title = titleText;
                return this;
            }

            public Builder withDescription(String descriptionText) {
                this.desc = descriptionText;
                return this;
            }

            public VerticalElement build() {
                return new VerticalElement(title, desc);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * 水平コンテンツリストアイテム
     */
    public static class HorizontalElement {
        /**
         * テンプレートカードの二次タイトル情報コンテンツタイプ (1: URL, 2: ファイル添付, 3: メンバー詳細へジャンプ)
         */
        private final Integer type;

        /**
         * 二次タイトル (5文字以内推奨)
         */
        private final String keyname;

        /**
         * 二次テキスト (typeが2の場合、ファイル名を含む。26文字以内推奨)
         */
        private final String value;

        /**
         * リンクジャンプURL (typeが1の場合に必須)
         */
        private final String url;

        /**
         * 添付ファイルのメディアID (typeが2の場合に必須)
         */
        private final String media_id;

        /**
         * メンバー詳細のUserID (typeが3の場合に必須)
         */
        private final String userid;

        private HorizontalElement(Integer type, String keyName, String valueText,
                                  String urlLink, String mediaId, String userId) {
            this.type = type;
            this.keyname = keyName;
            this.value = valueText;
            this.url = urlLink;
            this.media_id = mediaId;
            this.userid = userId;
        }

        public Integer getType() { return type; }
        public String getKeyname() { return keyname; }
        public String getValue() { return value; }
        public String getUrl() { return url; }
        public String getMedia_id() { return media_id; }
        public String getUserid() { return userid; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            HorizontalElement that = (HorizontalElement) o;
            return Objects.equals(type, that.type) &&
                    Objects.equals(keyname, that.keyname) &&
                    Objects.equals(value, that.value) &&
                    Objects.equals(url, that.url) &&
                    Objects.equals(media_id, that.media_id) &&
                    Objects.equals(userid, that.userid);
        }

        @Override
        public int hashCode() {
            return Objects.hash(type, keyname, value, url, media_id, userid);
        }

        @Override
        public String toString() {
            return "HorizontalElement{" +
                    "type=" + type +
                    ", keyname='" + keyname + '\'' +
                    ", value='" + value + '\'' +
                    ", url='" + url + '\'' +
                    ", media_id='" + media_id + '\'' +
                    ", userid='" + userid + '\'' +
                    '}';
        }

        public static class Builder {
            private Integer type = 0;
            private String keyname;
            private String value;
            private String url;
            private String media_id;
            private String userid;

            public Builder withType(Integer elementType) {
                this.type = elementType;
                return this;
            }

            public Builder withKeyname(String key) {
                this.keyname = key;
                return this;
            }

            public Builder withValue(String val) {
                this.value = val;
                return this;
            }

            public Builder withUrl(String urlLink) {
                this.url = urlLink;
                return this;
            }

            public Builder withMediaId(String mediaId) {
                this.media_id = mediaId;
                return this;
            }

            public Builder withUserid(String userId) {
                this.userid = userId;
                return this;
            }

            public HorizontalElement build() {
                return new HorizontalElement(type, keyname, value, url, media_id, userid);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * ジャンプガイドアイテム
     */
    public static class JumpAction {
        /**
         * ジャンプリンクタイプ (0: 非リンク, 1: URLへジャンプ, 2: ミニプログラムへジャンプ)
         */
        private final Integer type;

        /**
         * ジャンプリンクスタイルのテキストコンテンツ (13文字以内推奨)
         */
        private final String title;

        /**
         * ジャンプリンクのURL (typeが1の場合に必須)
         */
        private final String url;

        /**
         * ジャンプリンク先ミニプログラムのAppID (typeが2の場合に必須)
         */
        private final String appid;

        /**
         * ジャンプリンク先ミニプログラムのページパス (typeが2の場合にオプション)
         */
        private final String pagepath;

        private JumpAction(Integer type, String titleText, String urlLink, String appId, String pagePath) {
            this.type = type;
            this.title = titleText;
            this.url = urlLink;
            this.appid = appId;
            this.pagepath = pagePath;
        }

        public Integer getType() { return type; }
        public String getTitle() { return title; }
        public String getUrl() { return url; }
        public String getAppid() { return appid; }
        public String getPagepath() { return pagepath; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            JumpAction that = (JumpAction) o;
            return Objects.equals(type, that.type) &&
                    Objects.equals(title, that.title) &&
                    Objects.equals(url, that.url) &&
                    Objects.equals(appid, that.appid) &&
                    Objects.equals(pagepath, that.pagepath);
        }

        @Override
        public int hashCode() {
            return Objects.hash(type, title, url, appid, pagepath);
        }

        @Override
        public String toString() {
            return "JumpAction{" +
                    "type=" + type +
                    ", title='" + title + '\'' +
                    ", url='" + url + '\'' +
                    ", appid='" + appid + '\'' +
                    ", pagepath='" + pagepath + '\'' +
                    '}';
        }

        public static class Builder {
            private Integer type = 0;
            private String title;
            private String url;
            private String appid;
            private String pagepath;

            public Builder withType(Integer actionType) {
                this.type = actionType;
                return this;
            }

            public Builder withTitle(String titleText) {
                this.title = titleText;
                return this;
            }

            public Builder withUrl(String urlLink) {
                this.url = urlLink;
                return this;
            }

            public Builder withAppid(String appId) {
                this.appid = appId;
                return this;
            }

            public Builder withPagepath(String pagePath) {
                this.pagepath = pagePath;
                return this;
            }

            public JumpAction build() {
                return new JumpAction(type, title, url, appid, pagepath);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * カードクリック時のインタラクション
     */
    public static class CardInteraction {
        /**
         * カードジャンプタイプ (1: URLへジャンプ, 2: ミニプログラムを開く。news_noticeテンプレートカードでは[1,2]の範囲)
         */
        private final Integer type;

        /**
         * ジャンプイベントのURL (typeが1の場合に必須)
         */
        private final String url;

        /**
         * ジャンプイベントのミニプログラムAppID (typeが2の場合に必須)
         */
        private final String appid;

        /**
         * ジャンプイベントのミニプログラムページパス (typeが2の場合にオプション)
         */
        private final String pagepath;

        private CardInteraction(Integer type, String urlLink, String appId, String pagePath) {
            this.type = type;
            this.url = urlLink;
            this.appid = appId;
            this.pagepath = pagePath;
        }

        public Integer getType() { return type; }
        public String getUrl() { return url; }
        public String getAppid() { return appid; }
        public String getPagepath() { return pagepath; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            CardInteraction that = (CardInteraction) o;
            return Objects.equals(type, that.type) &&
                    Objects.equals(url, that.url) &&
                    Objects.equals(appid, that.appid) &&
                    Objects.equals(pagepath, that.pagepath);
        }

        @Override
        public int hashCode() {
            return Objects.hash(type, url, appid, pagepath);
        }

        @Override
        public String toString() {
            return "CardInteraction{" +
                    "type=" + type +
                    ", url='" + url + '\'' +
                    ", appid='" + appid + '\'' +
                    ", pagepath='" + pagepath + '\'' +
                    '}';
        }

        public static class Builder {
            private Integer type;
            private String url;
            private String appid;
            private String pagepath;

            public Builder withType(Integer interactionType) {
                this.type = interactionType;
                return this;
            }

            public Builder withUrl(String urlLink) {
                this.url = urlLink;
                return this;
            }

            public Builder withAppid(String appId) {
                this.appid = appId;
                return this;
            }

            public Builder withPagepath(String pagePath) {
                this.pagepath = pagePath;
                return this;
            }

            public CardInteraction build() {
                return new CardInteraction(type, url, appid, pagepath);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    public static void main(String[] args) {
        // シンプルなグラフィックテンプレートカードを作成する例
        GraphicCardMessage.PrimaryTitle primaryTitle = GraphicCardMessage.PrimaryTitle.builder()
                .withTitle("企業WeChatへようこそ")
                .withDescription("友達があなたを企業WeChatに招待しています")
                .build();

        GraphicCardMessage.CardImageInfo cardImage = GraphicCardMessage.CardImageInfo.builder()
                .withUrl("https://wework.qpic.cn/wwpic/354393_4zpkKXd7SrGMvfg_1629280616/0")
                .withAspectRatio(2.25f)
                .build();

        GraphicCardMessage.CardInteraction cardAction = GraphicCardMessage.CardInteraction.builder()
                .withType(1)
                .withUrl("https://work.weixin.qq.com/?from=openApi")
                .build();

        GraphicCardMessage simpleCard = GraphicCardMessage.createSimpleCard(primaryTitle, cardImage, cardAction);
        System.out.println("Simple Card: " + simpleCard);


        // 複雑なグラフィックテンプレートカードを作成する例
        GraphicCardMessage.SourceInfo source = GraphicCardMessage.SourceInfo.builder()
                .withIconUrl("https://wework.qpic.cn/wwpic/252813_jOfDHtcISzuodLa_1629280209/0")
                .withDescription("企業WeChat")
                .withDescriptionColor(0)
                .build();

        GraphicCardMessage.ImageTextBlock imageTextArea = GraphicCardMessage.ImageTextBlock.builder()
                .withType(1)
                .withUrl("https://work.weixin.qq.com")
                .withTitle("企業WeChatへようこそ")
                .withDescription("友達があなたを企業WeChatに招待しています")
                .withImageUrl("https://wework.qpic.cn/wwpic/354393_4zpkKXd7SrGMvfg_1629280616/0")
                .build();

        GraphicCardMessage.QuoteBlock quoteArea = GraphicCardMessage.QuoteBlock.builder()
                .withType(1)
                .withUrl("https://work.weixin.qq.com/?from=openApi")
                .withTitle("引用テキストタイトル")
                .withQuoteText("Jack:企業WeChatは本当に使いやすいです〜\nBalian:最高のソフトウェアです!")
                .build();

        List<VerticalElement> verticalContents = Arrays.asList(
                GraphicCardMessage.VerticalElement.builder()
                        .withTitle("サプライズ紅包をGET")
                        .withDescription("企業WeChatをダウンロードして紅包をゲットしよう!")
                        .build()
        );

        List<HorizontalElement> horizontalContents = Arrays.asList(
                GraphicCardMessage.HorizontalElement.builder()
                        .withKeyname("招待者")
                        .withValue("張三")
                        .build(),
                GraphicCardMessage.HorizontalElement.builder()
                        .withType(1)
                        .withKeyname("企業WeChat公式サイト")
                        .withValue("アクセスする")
                        .withUrl("https://work.weixin.qq.com/?from=openApi")
                        .build(),
                GraphicCardMessage.HorizontalElement.builder()
                        .withType(2)
                        .withKeyname("企業WeChatダウンロード")
                        .withValue("企業WeChat.apk")
                        .withMediaId("38Kz4vLCb49gfGvfOPw_y2f4nrik8lA_1xqNFmIzhtgkZVBoLoEYDQ7WopEKGnCTG")
                        .build()
        );

        List<JumpAction> jumpList = Arrays.asList(
                GraphicCardMessage.JumpAction.builder()
                        .withType(1)
                        .withTitle("企業WeChat公式サイト")
                        .withUrl("https://work.weixin.qq.com/?from=openApi")
                        .build(),
                GraphicCardMessage.JumpAction.builder()
                        .withType(2)
                        .withTitle("ミニプログラムへジャンプ")
                        .withAppid("APPID")
                        .withPagepath("PAGEPATH")
                        .build()
        );

        GraphicCardMessage complexCard = GraphicCardMessage.newCardBuilder()
                .withSourceInfo(source)
                .withPrimaryTitle(primaryTitle)
                .withCardImageInfo(cardImage)
                .withImageTextBlock(imageTextArea)
                .withQuoteBlock(quoteArea)
                .withVerticalContentItems(verticalContents)
                .withHorizontalContentItems(horizontalContents)
                .withJumpActions(jumpList)
                .withCardInteraction(cardAction)
                .build();

        System.out.println("Complex Card: " + complexCard);
    }
}

テキストテンプレートカードメッセージ

import java.util.Arrays;
import java.util.List;
import java.util.Objects;

/**
 * テキスト通知テンプレートカードメッセージのデータ構造
 */
public class TextCardMessage extends MessagePayload {

    private final TemplateCardLayout templateCard;

    private TextCardMessage(TemplateCardLayout cardLayout) {
        super("template_card");
        this.templateCard = cardLayout;
    }

    public TemplateCardLayout getTemplateCard() {
        return templateCard;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        TextCardMessage that = (TextCardMessage) o;
        return Objects.equals(templateCard, that.templateCard);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), templateCard);
    }

    @Override
    public String toString() {
        return "TextCardMessage(super=" + super.toString() +
                ", templateCard=" + templateCard + ")";
    }

    /**
     * シンプルなテキスト通知テンプレートカードメッセージを作成します。
     * @param primaryTitle 主タイトル
     * @param cardInteraction カードクリック時の動作
     * @return メッセージペイロード
     */
    public static TextCardMessage createSimpleCard(PrimaryTitle primaryTitle, CardInteraction cardInteraction) {
        return newCardBuilder()
                .withCardType("text_notice")
                .withPrimaryTitle(primaryTitle)
                .withCardInteraction(cardInteraction)
                .build();
    }

    /**
     * テンプレートカードのビルダーを作成します。
     */
    public static CardBuilder newCardBuilder() {
        return new CardBuilder();
    }

    /**
     * テンプレートカードメッセージのビルダー
     */
    public static class CardBuilder {
        private String cardType = "text_notice";
        private SourceInfo sourceInformation;
        private PrimaryTitle mainTitle;
        private KeyData emphasisContent;
        private QuoteBlock quoteArea;
        private String subTitleText;
        private List<HorizontalElement> horizontalContentItems;
        private List<JumpAction> jumpActions;
        private CardInteraction cardAction;

        public CardBuilder withCardType(String type) {
            this.cardType = type;
            return this;
        }

        public CardBuilder withSourceInfo(SourceInfo info) {
            this.sourceInformation = info;
            return this;
        }

        public CardBuilder withPrimaryTitle(PrimaryTitle title) {
            this.mainTitle = title;
            return this;
        }

        public CardBuilder withEmphasisContent(KeyData content) {
            this.emphasisContent = content;
            return this;
        }

        public CardBuilder withQuoteArea(QuoteBlock block) {
            this.quoteArea = block;
            return this;
        }

        public CardBuilder withSubTitleText(String text) {
            this.subTitleText = text;
            return this;
        }

        public CardBuilder withHorizontalContentItems(List<HorizontalElement> items) {
            this.horizontalContentItems = items;
            return this;
        }

        public CardBuilder withJumpActions(List<JumpAction> actions) {
            this.jumpActions = actions;
            return this;
        }

        public CardBuilder withCardInteraction(CardInteraction action) {
            this.cardAction = action;
            return this;
        }

        /**
         * テンプレートカードメッセージを構築します。
         * 検証: primaryTitle.title または subTitleText のいずれかは必須です。
         */
        public TextCardMessage build() {
            // 必須条件の検証
            if (mainTitle == null || (mainTitle.getTitle() == null && subTitleText == null)) {
                throw new IllegalArgumentException("主タイトル (mainTitle.title) とサブタイトルテキスト (subTitleText) のいずれかは必須です。");
            }
            if (cardAction == null) {
                throw new IllegalArgumentException("テキスト通知テンプレートカードではカードインタラクション (cardAction) は必須です。");
            }

            TemplateCardLayout cardLayout = new TemplateCardLayout(
                    cardType, sourceInformation, mainTitle, emphasisContent, quoteArea,
                    subTitleText, horizontalContentItems, jumpActions, cardAction
            );
            return new TextCardMessage(cardLayout);
        }
    }

    /**
     * テンプレートカードのレイアウト定義
     */
    public static class TemplateCardLayout {
        /**
         * テンプレートカードの種類 (テキスト通知テンプレートは "text_notice")
         */
        private final String card_type;

        /**
         * カードのソーススタイル情報 (不要な場合は省略可)
         */
        private final SourceInfo source;

        /**
         * テンプレートカードの主要コンテンツ (一次タイトルと補助情報を含む)
         */
        private final PrimaryTitle main_title;

        /**
         * キーデータスタイル
         */
        private final KeyData emphasis_content;

        /**
         * 引用文献スタイル (キーデータとの併用は推奨されません)
         */
        private final QuoteBlock quote_area;

        /**
         * 二次通常テキスト (112文字以内推奨)。
         * テンプレートカード主要コンテンツの一次タイトル (main_title.title) と
         * 二次通常テキスト (sub_title_text) のいずれかは必須です。
         */
        private final String sub_title_text;

        /**
         * 二次タイトル+テキストリスト (空配列でも可、データがある場合は対応するフィールドの必須性を確認。リスト長は6以下)
         */
        private final List<HorizontalElement> horizontal_content_list;

        /**
         * ジャンプガイドスタイルのリスト (空配列でも可、データがある場合は対応するフィールドの必須性を確認。リスト長は3以下)
         */
        private final List<JumpAction> jump_list;

        /**
         * カード全体のクリックジャンプイベント (text_noticeテンプレートカードでは必須)
         */
        private final CardInteraction card_action;

        public TemplateCardLayout(String cardType, SourceInfo source, PrimaryTitle mainTitle,
                                  KeyData emphasisContent, QuoteBlock quoteArea,
                                  String subTitleText, List<HorizontalElement> horizontalContentItems,
                                  List<JumpAction> jumpActions, CardInteraction cardAction) {
            this.card_type = cardType;
            this.source = source;
            this.main_title = mainTitle;
            this.emphasis_content = emphasisContent;
            this.quote_area = quoteArea;
            this.sub_title_text = subTitleText;
            this.horizontal_content_list = horizontalContentItems;
            this.jump_list = jumpActions;
            this.card_action = cardAction;
        }

        public String getCard_type() { return card_type; }
        public SourceInfo getSource() { return source; }
        public PrimaryTitle getMain_title() { return main_title; }
        public KeyData getEmphasis_content() { return emphasis_content; }
        public QuoteBlock getQuote_area() { return quote_area; }
        public String getSub_title_text() { return sub_title_text; }
        public List<HorizontalElement> getHorizontal_content_list() { return horizontal_content_list; }
        public List<JumpAction> getJump_list() { return jump_list; }
        public CardInteraction getCard_action() { return card_action; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            TemplateCardLayout that = (TemplateCardLayout) o;
            return Objects.equals(card_type, that.card_type) &&
                    Objects.equals(source, that.source) &&
                    Objects.equals(main_title, that.main_title) &&
                    Objects.equals(emphasis_content, that.emphasis_content) &&
                    Objects.equals(quote_area, that.quote_area) &&
                    Objects.equals(sub_title_text, that.sub_title_text) &&
                    Objects.equals(horizontal_content_list, that.horizontal_content_list) &&
                    Objects.equals(jump_list, that.jump_list) &&
                    Objects.equals(card_action, that.card_action);
        }

        @Override
        public int hashCode() {
            return Objects.hash(card_type, source, main_title, emphasis_content, quote_area,
                    sub_title_text, horizontal_content_list, jump_list, card_action);
        }

        @Override
        public String toString() {
            return "TemplateCardLayout{" +
                    "card_type='" + card_type + '\'' +
                    ", source=" + source +
                    ", main_title=" + main_title +
                    ", emphasis_content=" + emphasis_content +
                    ", quote_area=" + quote_area +
                    ", sub_title_text='" + sub_title_text + '\'' +
                    ", horizontal_content_list=" + horizontal_content_list +
                    ", jump_list=" + jump_list +
                    ", card_action=" + card_action +
                    '}';
        }
    }

    /**
     * カードのソース情報 (GraphicCardMessageと共有)
     */
    public static class SourceInfo {
        /**
         * ソース画像のURL
         */
        private final String icon_url;

        /**
         * ソース画像の記述 (13文字以内推奨)
         */
        private final String desc;

        /**
         * ソーステキストの色 (0: デフォルト(灰色), 1: 黒, 2: 赤, 3: 緑 をサポート)
         */
        private final Integer desc_color;

        private SourceInfo(String iconUrl, String description, Integer descriptionColor) {
            this.icon_url = iconUrl;
            this.desc = description;
            this.desc_color = descriptionColor;
        }

        public String getIcon_url() { return icon_url; }
        public String getDesc() { return desc; }
        public Integer getDesc_color() { return desc_color; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            SourceInfo that = (SourceInfo) o;
            return Objects.equals(icon_url, that.icon_url) &&
                    Objects.equals(desc, that.desc) &&
                    Objects.equals(desc_color, that.desc_color);
        }

        @Override
        public int hashCode() {
            return Objects.hash(icon_url, desc, desc_color);
        }

        @Override
        public String toString() {
            return "SourceInfo{" +
                    "icon_url='" + icon_url + '\'' +
                    ", desc='" + desc + '\'' +
                    ", desc_color=" + desc_color +
                    '}';
        }

        public static class Builder {
            private String iconUrl;
            private String description;
            private Integer descriptionColor = 0;

            public Builder withIconUrl(String url) {
                this.iconUrl = url;
                return this;
            }

            public Builder withDescription(String desc) {
                this.description = desc;
                return this;
            }

            public Builder withDescriptionColor(Integer color) {
                this.descriptionColor = color;
                return this;
            }

            public SourceInfo build() {
                return new SourceInfo(iconUrl, description, descriptionColor);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * テンプレートカードの主要タイトルコンテンツ (GraphicCardMessageと共有)
     */
    public static class PrimaryTitle {
        /**
         * 一次タイトル (26文字以内推奨)。
         * テンプレートカード主要コンテンツの一次タイトル (main_title.title) と
         * 二次通常テキスト (sub_title_text) のいずれかは必須です。
         */
        private final String title;

        /**
         * タイトルの補助情報 (30文字以内推奨)
         */
        private final String desc;

        private PrimaryTitle(String titleText, String descriptionText) {
            this.title = titleText;
            this.desc = descriptionText;
        }

        public String getTitle() { return title; }
        public String getDesc() { return desc; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            PrimaryTitle that = (PrimaryTitle) o;
            return Objects.equals(title, that.title) &&
                    Objects.equals(desc, that.desc);
        }

        @Override
        public int hashCode() {
            return Objects.hash(title, desc);
        }

        @Override
        public String toString() {
            return "PrimaryTitle{" +
                    "title='" + title + '\'' +
                    ", desc='" + desc + '\'' +
                    '}';
        }

        public static class Builder {
            private String title;
            private String desc;

            public Builder withTitle(String titleText) {
                this.title = titleText;
                return this;
            }

            public Builder withDescription(String descriptionText) {
                this.desc = descriptionText;
                return this;
            }

            public PrimaryTitle build() {
                return new PrimaryTitle(title, desc);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * キーデータスタイル
     */
    public static class KeyData {
        /**
         * キーデータのデータ内容 (10文字以内推奨)
         */
        private final String title;

        /**
         * キーデータの記述内容 (15文字以内推奨)
         */
        private final String desc;

        private KeyData(String titleText, String descriptionText) {
            this.title = titleText;
            this.desc = descriptionText;
        }

        public String getTitle() { return title; }
        public String getDesc() { return desc; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            KeyData that = (KeyData) o;
            return Objects.equals(title, that.title) &&
                    Objects.equals(desc, that.desc);
        }

        @Override
        public int hashCode() {
            return Objects.hash(title, desc);
        }

        @Override
        public String toString() {
            return "KeyData{" +
                    "title='" + title + '\'' +
                    ", desc='" + desc + '\'' +
                    '}';
        }

        public static class Builder {
            private String title;
            private String desc;

            public Builder withTitle(String titleText) {
                this.title = titleText;
                return this;
            }

            public Builder withDescription(String descriptionText) {
                this.desc = descriptionText;
                return this;
            }

            public KeyData build() {
                return new KeyData(title, desc);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * 引用文献スタイルのブロック (GraphicCardMessageと共有)
     */
    public static class QuoteBlock {
        /**
         * クリックイベントタイプ (0: なし, 1: URLへジャンプ, 2: ミニプログラムへジャンプ)
         */
        private final Integer type;

        /**
         * クリックジャンプURL (typeが1の場合に必須)
         */
        private final String url;

        /**
         * クリックジャンプ先ミニプログラムのAppID (typeが2の場合に必須)
         */
        private final String appid;

        /**
         * クリックジャンプ先ミニプログラムのページパス (typeが2の場合にオプション)
         */
        private final String pagepath;

        /**
         * 引用文献ブロックのタイトル
         */
        private final String title;

        /**
         * 引用文献ブロックの引用テキスト
         */
        private final String quote_text;

        private QuoteBlock(Integer type, String url, String appId, String pagePath,
                           String titleText, String quoteText) {
            this.type = type;
            this.url = url;
            this.appid = appId;
            this.pagepath = pagePath;
            this.title = titleText;
            this.quote_text = quoteText;
        }

        public Integer getType() { return type; }
        public String getUrl() { return url; }
        public String getAppid() { return appid; }
        public String getPagepath() { return pagepath; }
        public String getTitle() { return title; }
        public String getQuote_text() { return quote_text; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            QuoteBlock that = (QuoteBlock) o;
            return Objects.equals(type, that.type) &&
                    Objects.equals(url, that.url) &&
                    Objects.equals(appid, that.appid) &&
                    Objects.equals(pagepath, that.pagepath) &&
                    Objects.equals(title, that.title) &&
                    Objects.equals(quote_text, that.quote_text);
        }

        @Override
        public int hashCode() {
            return Objects.hash(type, url, appid, pagepath, title, quote_text);
        }

        @Override
        public String toString() {
            return "QuoteBlock{" +
                    "type=" + type +
                    ", url='" + url + '\'' +
                    ", appid='" + appid + '\'' +
                    ", pagepath='" + pagepath + '\'' +
                    ", title='" + title + '\'' +
                    ", quote_text='" + quote_text + '\'' +
                    '}';
        }

        public static class Builder {
            private Integer type = 0;
            private String url;
            private String appid;
            private String pagepath;
            private String title;
            private String quote_text;

            public Builder withType(Integer clickType) {
                this.type = clickType;
                return this;
            }

            public Builder withUrl(String clickUrl) {
                this.url = clickUrl;
                return this;
            }

            public Builder withAppid(String appId) {
                this.appid = appId;
                return this;
            }

            public Builder withPagepath(String pagePath) {
                this.pagepath = pagePath;
                return this;
            }

            public Builder withTitle(String titleText) {
                this.title = titleText;
                return this;
            }

            public Builder withQuoteText(String quoteText) {
                this.quote_text = quoteText;
                return this;
            }

            public QuoteBlock build() {
                return new QuoteBlock(type, url, appid, pagepath, title, quote_text);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * 水平コンテンツリストアイテム (GraphicCardMessageと共有)
     */
    public static class HorizontalElement {
        /**
         * テンプレートカードの二次タイトル情報コンテンツタイプ (1: URL, 2: ファイル添付, 3: メンバー詳細へジャンプ)
         */
        private final Integer type;

        /**
         * 二次タイトル (5文字以内推奨)
         */
        private final String keyname;

        /**
         * 二次テキスト (typeが2の場合、ファイル名を含む。26文字以内推奨)
         */
        private final String value;

        /**
         * リンクジャンプURL (typeが1の場合に必須)
         */
        private final String url;

        /**
         * 添付ファイルのメディアID (typeが2の場合に必須)
         */
        private final String media_id;

        /**
         * メンバー詳細のUserID (typeが3の場合に必須)
         */
        private final String userid;

        private HorizontalElement(Integer type, String keyName, String valueText,
                                  String urlLink, String mediaId, String userId) {
            this.type = type;
            this.keyname = keyName;
            this.value = valueText;
            this.url = urlLink;
            this.media_id = mediaId;
            this.userid = userId;
        }

        public Integer getType() { return type; }
        public String getKeyname() { return keyname; }
        public String getValue() { return value; }
        public String getUrl() { return url; }
        public String getMedia_id() { return media_id; }
        public String getUserid() { return userid; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            HorizontalElement that = (HorizontalElement) o;
            return Objects.equals(type, that.type) &&
                    Objects.equals(keyname, that.keyname) &&
                    Objects.equals(value, that.value) &&
                    Objects.equals(url, that.url) &&
                    Objects.equals(media_id, that.media_id) &&
                    Objects.equals(userid, that.userid);
        }

        @Override
        public int hashCode() {
            return Objects.hash(type, keyname, value, url, media_id, userid);
        }

        @Override
        public String toString() {
            return "HorizontalElement{" +
                    "type=" + type +
                    ", keyname='" + keyname + '\'' +
                    ", value='" + value + '\'' +
                    ", url='" + url + '\'' +
                    ", media_id='" + media_id + '\'' +
                    ", userid='" + userid + '\'' +
                    '}';
        }

        public static class Builder {
            private Integer type = 0;
            private String keyname;
            private String value;
            private String url;
            private String media_id;
            private String userid;

            public Builder withType(Integer elementType) {
                this.type = elementType;
                return this;
            }

            public Builder withKeyname(String key) {
                this.keyname = key;
                return this;
            }

            public Builder withValue(String val) {
                this.value = val;
                return this;
            }

            public Builder withUrl(String urlLink) {
                this.url = urlLink;
                return this;
            }

            public Builder withMediaId(String mediaId) {
                this.media_id = mediaId;
                return this;
            }

            public Builder withUserid(String userId) {
                this.userid = userId;
                return this;
            }

            public HorizontalElement build() {
                return new HorizontalElement(type, keyname, value, url, media_id, userid);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * ジャンプガイドアイテム (GraphicCardMessageと共有)
     */
    public static class JumpAction {
        /**
         * ジャンプリンクタイプ (0: 非リンク, 1: URLへジャンプ, 2: ミニプログラムへジャンプ)
         */
        private final Integer type;

        /**
         * ジャンプリンクスタイルのテキストコンテンツ (13文字以内推奨)
         */
        private final String title;

        /**
         * ジャンプリンクのURL (typeが1の場合に必須)
         */
        private final String url;

        /**
         * ジャンプリンク先ミニプログラムのAppID (typeが2の場合に必須)
         */
        private final String appid;

        /**
         * ジャンプリンク先ミニプログラムのページパス (typeが2の場合にオプション)
         */
        private final String pagepath;

        private JumpAction(Integer type, String titleText, String urlLink, String appId, String pagePath) {
            this.type = type;
            this.title = titleText;
            this.url = urlLink;
            this.appid = appId;
            this.pagepath = pagePath;
        }

        public Integer getType() { return type; }
        public String getTitle() { return title; }
        public String getUrl() { return url; }
        public String getAppid() { return appid; }
        public String getPagepath() { return pagepath; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            JumpAction that = (JumpAction) o;
            return Objects.equals(type, that.type) &&
                    Objects.equals(title, that.title) &&
                    Objects.equals(url, that.url) &&
                    Objects.equals(appid, that.appid) &&
                    Objects.equals(pagepath, that.pagepath);
        }

        @Override
        public int hashCode() {
            return Objects.hash(type, title, url, appid, pagepath);
        }

        @Override
        public String toString() {
            return "JumpAction{" +
                    "type=" + type +
                    ", title='" + title + '\'' +
                    ", url='" + url + '\'' +
                    ", appid='" + appid + '\'' +
                    ", pagepath='" + pagepath + '\'' +
                    '}';
        }

        public static class Builder {
            private Integer type = 0;
            private String title;
            private String url;
            private String appid;
            private String pagepath;

            public Builder withType(Integer actionType) {
                this.type = actionType;
                return this;
            }

            public Builder withTitle(String titleText) {
                this.title = titleText;
                return this;
            }

            public Builder withUrl(String urlLink) {
                this.url = urlLink;
                return this;
            }

            public Builder withAppid(String appId) {
                this.appid = appId;
                return this;
            }

            public Builder withPagepath(String pagePath) {
                this.pagepath = pagePath;
                return this;
            }

            public JumpAction build() {
                return new JumpAction(type, title, url, appid, pagepath);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    /**
     * カードクリック時のインタラクション (GraphicCardMessageと共有)
     */
    public static class CardInteraction {
        /**
         * カードジャンプタイプ (1: URLへジャンプ, 2: ミニプログラムを開く。text_noticeテンプレートカードでは[1,2]の範囲)
         */
        private final Integer type;

        /**
         * ジャンプイベントのURL (typeが1の場合に必須)
         */
        private final String url;

        /**
         * ジャンプイベントのミニプログラムAppID (typeが2の場合に必須)
         */
        private final String appid;

        /**
         * ジャンプイベントのミニプログラムページパス (typeが2の場合にオプション)
         */
        private final String pagepath;

        private CardInteraction(Integer type, String urlLink, String appId, String pagePath) {
            this.type = type;
            this.url = urlLink;
            this.appid = appId;
            this.pagepath = pagePath;
        }

        public Integer getType() { return type; }
        public String getUrl() { return url; }
        public String getAppid() { return appid; }
        public String getPagepath() { return pagepath; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            CardInteraction that = (CardInteraction) o;
            return Objects.equals(type, that.type) &&
                    Objects.equals(url, that.url) &&
                    Objects.equals(appid, that.appid) &&
                    Objects.equals(pagepath, that.pagepath);
        }

        @Override
        public int hashCode() {
            return Objects.hash(type, url, appid, pagepath);
        }

        @Override
        public String toString() {
            return "CardInteraction{" +
                    "type=" + type +
                    ", url='" + url + '\'' +
                    ", appid='" + appid + '\'' +
                    ", pagepath='" + pagepath + '\'' +
                    '}';
        }

        public static class Builder {
            private Integer type;
            private String url;
            private String appid;
            private String pagepath;

            public Builder withType(Integer interactionType) {
                this.type = interactionType;
                return this;
            }

            public Builder withUrl(String urlLink) {
                this.url = urlLink;
                return this;
            }

            public Builder withAppid(String appId) {
                this.appid = appId;
                return this;
            }

            public Builder withPagepath(String pagePath) {
                this.pagepath = pagePath;
                return this;
            }

            public CardInteraction build() {
                return new CardInteraction(type, url, appid, pagepath);
            }
        }

        public static Builder builder() {
            return new Builder();
        }
    }

    public static void main(String[] args) {
        // シンプルなテキスト通知テンプレートカードを作成する例
        TextCardMessage.PrimaryTitle mainTitle = TextCardMessage.PrimaryTitle.builder()
                .withTitle("企業WeChatへようこそ")
                .withDescription("友達があなたを企業WeChatに招待しています")
                .build();

        TextCardMessage.CardInteraction cardAction = TextCardMessage.CardInteraction.builder()
                .withType(1)
                .withUrl("https://work.weixin.qq.com/?from=openApi")
                .build();

        TextCardMessage simpleCard = TextCardMessage.createSimpleCard(mainTitle, cardAction);
        System.out.println("Simple Text Card: " + simpleCard);


        // 複雑なテキスト通知テンプレートカードを作成する例
        TextCardMessage.SourceInfo source = TextCardMessage.SourceInfo.builder()
                .withIconUrl("https://wework.qpic.cn/wwpic/252813_jOfDHtcISzuodLa_1629280209/0")
                .withDescription("企業WeChat")
                .withDescriptionColor(0)
                .build();

        TextCardMessage.KeyData emphasisContent = TextCardMessage.KeyData.builder()
                .withTitle("100")
                .withDescription("データ意味")
                .build();

        TextCardMessage.QuoteBlock quoteArea = TextCardMessage.QuoteBlock.builder()
                .withType(1)
                .withUrl("https://work.weixin.qq.com/?from=openApi")
                .withTitle("引用テキストタイトル")
                .withQuoteText("Jack:企業WeChatは本当に使いやすいです〜\nBalian:最高のソフトウェアです!")
                .build();

        List<HorizontalElement> horizontalContents = Arrays.asList(
                TextCardMessage.HorizontalElement.builder()
                        .withKeyname("招待者")
                        .withValue("張三")
                        .build(),
                TextCardMessage.HorizontalElement.builder()
                        .withType(1)
                        .withKeyname("企業WeChat公式サイト")
                        .withValue("アクセスする")
                        .withUrl("https://work.weixin.qq.com/?from=openApi")
                        .build(),
                TextCardMessage.HorizontalElement.builder()
                        .withType(2)
                        .withKeyname("企業WeChatダウンロード")
                        .withValue("企業WeChat.apk")
                        .withMediaId("38Kz4vLCb49gfGvfOPw_y2f4nrik8lA_1xqNFmIzhtgkZVBoLoEYDQ7WopEKGnCTG")
                        .build()
        );

        List<JumpAction> jumpList = Arrays.asList(
                TextCardMessage.JumpAction.builder()
                        .withType(1)
                        .withTitle("企業WeChat公式サイト")
                        .withUrl("https://work.weixin.qq.com/?from=openApi")
                        .build()
        );

        TextCardMessage complexCard = TextCardMessage.newCardBuilder()
                .withSourceInfo(source)
                .withPrimaryTitle(mainTitle)
                .withEmphasisContent(emphasisContent)
                .withQuoteArea(quoteArea)
                .withSubTitleText("企業WeChatをダウンロードして紅包をゲットしよう!")
                .withHorizontalContentItems(horizontalContents)
                .withJumpActions(jumpList)
                .withCardInteraction(cardAction)
                .build();

        System.out.println("Complex Text Card: " + complexCard);
    }

}

テキストメッセージ

import java.util.List;
import java.util.Objects;

/**
 * テキストタイプメッセージのデータ構造
 */
public class TextMessage extends MessagePayload {
    private final TextContent text;

    TextMessage(TextContent textData) {
        super("text");
        this.text = textData;
    }

    public TextContent getText() {
        return text;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        TextMessage that = (TextMessage) o;
        return Objects.equals(text, that.text);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), text);
    }

    @Override
    public String toString() {
        return "TextMessage(super=" + super.toString() + ", text=" + text + ")";
    }

    /**
     * テキストメッセージを構築します。
     * @param content テキスト内容
     * @return
     */
    public static TextMessage of(String content) {
        return of(content, null, null);
    }

    /**
     * テキストメッセージを構築します。
     * @param content テキスト内容
     * @param mentionedMobiles メンションする携帯電話番号のリスト (@allで全員をメンション)
     * @return
     */
    public static TextMessage of(String content, List<String> mentionedMobiles) {
        return of(content, null, mentionedMobiles);
    }

    /**
     * テキストメッセージを構築します。
     * @param content テキスト内容
     * @param mentionedUsers メンションするユーザーIDのリスト (@allで全員をメンション。ユーザーIDが不明な場合はmentionedMobilesを使用)
     * @param mentionedMobiles メンションする携帯電話番号のリスト (@allで全員をメンション)
     * @return
     */
    public static TextMessage of(String content, List<String> mentionedUsers, List<String> mentionedMobiles) {
        TextContent textContent = new TextContent(content);
        textContent.setMentionedUsers(mentionedUsers);
        textContent.setMentionedMobiles(mentionedMobiles);
        return new TextMessage(textContent);
    }

    /**
     * テキストメッセージの具体的な内容
     */
    public static class TextContent {
        /**
         * テキスト内容 (最大2048バイト、UTF-8エンコード)
         */
        private final String content;
        /**
         * メンションするユーザーIDのリスト (@allで全員をメンション)
         */
        private List<String> mentioned_list;
        /**
         * メンションする携帯電話番号のリスト (@allで全員をメンション)
         */
        private List<String> mentioned_mobile_list;

        public TextContent(String content) {
            this.content = content;
        }

        public String getContent() {
            return content;
        }

        public List<String> getMentionedUsers() {
            return mentioned_list;
        }

        public List<String> getMentionedMobiles() {
            return mentioned_mobile_list;
        }

        public void setMentionedUsers(List<String> mentionedUsers) {
            this.mentioned_list = mentionedUsers;
        }

        public void setMentionedMobiles(List<String> mentionedMobiles) {
            this.mentioned_mobile_list = mentionedMobiles;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            TextContent that = (TextContent) o;
            return Objects.equals(content, that.content) &&
                    Objects.equals(mentioned_list, that.mentioned_list) &&
                    Objects.equals(mentioned_mobile_list, that.mentioned_mobile_list);
        }

        @Override
        public int hashCode() {
            return Objects.hash(content, mentioned_list, mentioned_mobile_list);
        }

        @Override
        public String toString() {
            return "TextContent{" +
                    "content='" + content + '\'' +
                    ", mentioned_list=" + mentioned_list +
                    ", mentioned_mobile_list=" + mentioned_mobile_list +
                    '}';
        }
    }
}

メディアアップロード応答

import java.util.Objects;

public class MediaUploadResponse extends ApiResponse {

    private String type;
    private String media_id;
    private String created_at;

    public MediaUploadResponse(){}

    public MediaUploadResponse(Integer errorCode, String errorMessage, String type, String mediaId, String createdAt) {
        super(errorCode, errorMessage); // Call super constructor if ApiResponse also has one
        this.type = type;
        this.media_id = mediaId;
        this.created_at = createdAt;
    }

    public String getMedia_id() {
        return media_id;
    }

    public void setMedia_id(String mediaId) {
        this.media_id = mediaId;
    }

    public String getType() {
        return type;
    }

    public void setType(String type) {
        this.type = type;
    }

    public String getCreated_at() {
        return created_at;
    }

    public void setCreated_at(String createdAt) {
        this.created_at = createdAt;
    }

    @Override
    public String toString() {
        return "MediaUploadResponse{" +
                "errorCode=" + getErrorCode() +
                ", errorMessage='" + getErrorMessage() + '\'' +
                ", type='" + type + '\'' +
                ", media_id='" + media_id + '\'' +
                ", created_at='" + created_at + '\'' +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        MediaUploadResponse that = (MediaUploadResponse) o;
        return Objects.equals(type, that.type) &&
                Objects.equals(media_id, that.media_id) &&
                Objects.equals(created_at, that.created_at);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), type, media_id, created_at);
    }
}

音声メッセージ

import java.util.Objects;

/**
 * 音声タイプメッセージのデータ構造
 */
public class VoiceMessage extends MessagePayload {

    private final VoiceContent voice;

    private VoiceMessage(VoiceContent voiceData) {
        super("voice");
        this.voice = voiceData;
    }

    public VoiceContent getVoice() {
        return voice;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        VoiceMessage that = (VoiceMessage) o;
        return Objects.equals(voice, that.voice);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), voice);
    }

    @Override
    public String toString() {
        return "VoiceMessage(super=" + super.toString() +
                ", voice=" + voice + ")";
    }

    /**
     * 音声メッセージを構築します。
     * @param mediaId 音声ファイルのメディアID (アップロードAPIで取得)
     */
    public static VoiceMessage of(String mediaId) {
        if (mediaId == null || mediaId.trim().isEmpty()) {
            throw new IllegalArgumentException("メディアIDは空であってはなりません");
        }
        return new VoiceMessage(new VoiceContent(mediaId));
    }

    /**
     * 音声メッセージの具体的な内容
     */
    public static class VoiceContent {
        /**
         * 音声ファイルのメディアID
         */
        private final String media_id;

        public VoiceContent(String mediaId) {
            this.media_id = mediaId;
        }

        public String getMedia_id() {
            return media_id;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            VoiceContent that = (VoiceContent) o;
            return Objects.equals(media_id, that.media_id);
        }

        @Override
        public int hashCode() {
            return Objects.hash(media_id);
        }

        @Override
        public String toString() {
            return "VoiceContent(media_id=" + media_id + ")";
        }
    }
}

HTTPクライアント実装

メッセージペイロードを定義した後、実際に企業WeChatのWebhook APIと通信するためのHTTPクライアントを実装します。この実装は、Apache HttpClientを使用し、接続プーリング、タイムアウト設定、SSL証明書検証のスキップ(開発環境向け)などを考慮しています。

依存関係

pom.xml に以下の依存関係を追加します。

    <dependencies>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.2.11</version>
        </dependency>

        <!-- Apache HttpClient -->
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.13</version>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpmime</artifactId>
            <version>4.5.13</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.12.0</version>
        </dependency>
        <!-- Google Gson for JSON processing -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.10.1</version> <!-- 2025年時点の安定バージョンを推奨 -->
        </dependency>
    </dependencies>

HTTP通信ユーティリティ

import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpRequest;
import org.apache.http.client.HttpRequestRetryHandler;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.ConnectionKeepAliveStrategy;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.DefaultHttpRequestRetryHandler;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * シンプルなHTTP通信ユーティリティクラス
 */
public class SimpleHttpClient {

    private SimpleHttpClient() {
        // インスタンス化を防止
    }

    private final static Logger logger = LoggerFactory.getLogger(SimpleHttpClient.class);

    /** 接続確立のタイムアウト (ミリ秒) */
    private static final int CONNECT_TIMEOUT_MS = 3000;
    /** リクエスト送信のタイムアウト (ミリ秒) */
    private static final int REQUEST_TIMEOUT_MS = 30000;
    /** データ読み取りのタイムアウト (ミリ秒) */
    private static final int SOCKET_TIMEOUT_MS = 180000;
    /** 最大再試行回数 */
    private static final int MAX_RETRIES = 0; // Webhookは通常、べき等ではないため、再試行は無効にする
    /** コネクションプール全体の最大接続数 */
    private static final int MAX_TOTAL_CONNECTIONS = 100;
    /** ルートごとの最大接続数 */
    private static final int MAX_CONNECTIONS_PER_ROUTE = 5;
    /** アイドル状態の接続を維持する時間 (秒) */
    private static final long KEEP_ALIVE_DURATION_SEC = 60;

    // スレッドセーフなHttpClientインスタンス
    private static final CloseableHttpClient httpClientInstance;

    static {
        logger.info("HttpClientUtilityの初期化を開始します...");
        try {
            httpClientInstance = buildHttpClient();
        } catch (NoSuchAlgorithmException | KeyManagementException e) {
            logger.error("HttpClientの初期化に失敗しました。", e);
            throw new ExceptionInInitializerError(e);
        }
        logger.info("HttpClientUtilityの初期化が完了しました。");
    }

    /**
     * HttpClientインスタンスを取得します。
     * @return CloseableHttpClient
     */
    public static CloseableHttpClient getHttpClient() {
        return httpClientInstance;
    }

    /**
     * すべてのHTTPS証明書を信頼するためのTrustManager
     */
    static class InsecureTrustManager implements X509TrustManager {
        @Override
        public void checkClientTrusted(X509Certificate[] certs, String authType) { /* 何もしない */ }
        @Override
        public void checkServerTrusted(X509Certificate[] certs, String authType) { /* 何もしない */ }
        @Override
        public X509Certificate[] getAcceptedIssuers() {
            return new X509Certificate[0];
        }
    }

    /**
     * SSL証明書検証をスキップするSSLConnectionSocketFactoryを構築します。
     * @return SSLConnectionSocketFactory
     * @throws NoSuchAlgorithmException
     * @throws KeyManagementException
     */
    private static SSLConnectionSocketFactory createInsecureSslSocketFactory() throws NoSuchAlgorithmException, KeyManagementException {
        SSLContext sslContext = SSLContext.getInstance("TLS");
        sslContext.init(null, new TrustManager[]{new InsecureTrustManager()}, null);
        return new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
    }

    /**
     * カスタムHttpClientを構築します。
     *
     * @return CloseableHttpClient
     * @throws KeyManagementException
     * @throws NoSuchAlgorithmException
     */
    private static CloseableHttpClient buildHttpClient() throws KeyManagementException, NoSuchAlgorithmException {
        Registry<ConnectionSocketFactory> socketFactoryRegistry =
                RegistryBuilder.<ConnectionSocketFactory>create()
                        .register("http", PlainConnectionSocketFactory.INSTANCE)
                        .register("https", createInsecureSslSocketFactory()).build();

        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(socketFactoryRegistry);
        connectionManager.setMaxTotal(MAX_TOTAL_CONNECTIONS);
        connectionManager.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE);

        // Keep-Alive戦略
        ConnectionKeepAliveStrategy keepAliveStrategy = (response, context) -> KEEP_ALIVE_DURATION_SEC * 1000;

        return HttpClients.custom()
                .setDefaultRequestConfig(buildRequestConfig())
                .setConnectionManager(connectionManager)
                // Webhookは冪等ではない場合が多いので、基本的に再試行はしない
                .setRetryHandler(new DefaultHttpRequestRetryHandler(MAX_RETRIES, false))
                .evictExpiredConnections()
                .evictIdleConnections(KEEP_ALIVE_DURATION_SEC, TimeUnit.SECONDS)
                .setKeepAliveStrategy(keepAliveStrategy)
                .build();
    }

    /**
     * RequestConfigを構築します。
     *
     * @return RequestConfig
     */
    private static RequestConfig buildRequestConfig() {
        return RequestConfig.custom()
                .setConnectTimeout(CONNECT_TIMEOUT_MS)
                .setConnectionRequestTimeout(REQUEST_TIMEOUT_MS)
                .setSocketTimeout(SOCKET_TIMEOUT_MS)
                .build();
    }

    /**
     * HTTP POSTリクエストをJSON形式のボディで送信します。
     * Content-Type: application/json; charset=UTF-8
     *
     * @param url       リクエストURL
     * @param jsonBody  リクエストボディのJSON文字列
     * @param headers   リクエストヘッダーのマップ
     * @return 応答ボディの文字列
     */
    public static String sendJsonPost(String url, String jsonBody, Map<String, String> headers) {
        validateUrl(url);
        HttpPost httpPost = new HttpPost(url);
        httpPost.setEntity(new StringEntity(jsonBody, StandardCharsets.UTF_8));
        httpPost.setHeader("Content-Type", "application/json;charset=utf8");
        headers.forEach(httpPost::setHeader);

        try (CloseableHttpResponse response = getHttpClient().execute(httpPost)) {
            HttpEntity responseEntity = response.getEntity();
            if (responseEntity != null) {
                return EntityUtils.toString(responseEntity, StandardCharsets.UTF_8);
            }
            return null;
        } catch (IOException e) {
            logger.error("JSON POSTリクエストの実行に失敗しました: {} - {}", url, e.getMessage());
            throw new RuntimeException("JSON POSTリクエスト失敗: " + e.getMessage(), e);
        }
    }

    /**
     * HTTP POSTリクエストをmultipart/form-data形式で送信します。
     *
     * @param url       リクエストURL
     * @param formParams  フォームデータパラメータのマップ
     * @param headers   リクエストヘッダーのマップ
     * @param file      アップロードするファイル
     * @param fileFieldName ファイルフィールド名(例: "media")
     * @return 応答ボディの文字列
     */
    public static String sendMultipartPost(String url, Map<String, String> formParams, Map<String, String> headers, File file, String fileFieldName) {
        validateUrl(url);
        HttpPost httpPost = new HttpPost(url);
        MultipartEntityBuilder multipartEntityBuilder = MultipartEntityBuilder.create();

        // ファイルを追加
        if (file != null && file.exists()) {
            multipartEntityBuilder.addBinaryBody(fileFieldName, file, ContentType.DEFAULT_BINARY, encodeFileName(file.getName()));
        }

        // その他のパラメータを追加 (UTF-8エンコーディングを指定)
        ContentType textContentType = ContentType.create("text/plain", StandardCharsets.UTF_8);
        formParams.forEach((key, value) -> multipartEntityBuilder.addTextBody(key, value, textContentType));

        httpPost.setEntity(multipartEntityBuilder.build());
        headers.forEach(httpPost::setHeader);

        try (CloseableHttpResponse response = getHttpClient().execute(httpPost)) {
            HttpEntity responseEntity = response.getEntity();
            if (responseEntity != null) {
                return EntityUtils.toString(responseEntity, StandardCharsets.UTF_8);
            }
            return null;
        } catch (IOException e) {
            logger.error("Multipart POSTリクエストの実行に失敗しました: {} - {}", url, e.getMessage());
            throw new RuntimeException("Multipart POSTリクエスト失敗: " + e.getMessage(), e);
        }
    }

    /**
     * ファイル名をUTF-8でURLエンコードします。
     * @param fileName エンコードするファイル名
     * @return エンコードされたファイル名
     */
    private static String encodeFileName(String fileName) {
        try {
            return URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString());
        } catch (UnsupportedEncodingException e) {
            logger.warn("ファイル名のURLエンコードに失敗しました。ファイル名: {}", fileName, e);
            return fileName; // エンコード失敗時は元のファイル名を返す
        }
    }

    /**
     * URLが有効かどうかを検証します。
     * @param url 検証するURL
     */
    private static void validateUrl(String url) {
        if (StringUtils.isBlank(url)) {
            logger.error("HTTPクライアントのリクエストURLは空であってはなりません。");
            throw new IllegalArgumentException("リクエストURLが空です。");
        }
    }

    /**
     * リソースを安全に閉じます。
     * @param closeable 閉じるリソース
     */
    private static void closeResource(Closeable closeable) {
        try {
            if (closeable != null) {
                closeable.close();
            }
        } catch (IOException e) {
            logger.warn("リソースのクローズに失敗しました。", e);
        }
    }
}

企業WeChatボットサービスの実装

最後に、定義したインターフェースをHTTPクライアントユーティリティとGoogle Gsonライブラリを使用して実装します。

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

import java.io.File;
import java.util.HashMap;
import java.util.Map;

/**
 * 企業WeChat Webhook APIのデフォルト実装サービス
 */
public class WeChatBotService implements WeChatBotApiClient {

    private final String webhookBaseUrl;
    private final Gson gson;

    public WeChatBotService(String baseUrl) {
        this.webhookBaseUrl = baseUrl;
        this.gson = new GsonBuilder().disableHtmlEscaping().create(); // HTMLエスケープを無効にすることも検討
    }

    /**
     * 指定されたペイロードに基づいてメッセージを送信します。
     * @param botKey ボットのユニークな識別子
     * @param payload 送信するメッセージのデータ構造
     * @return APIからの応答
     */
    @Override
    public ApiResponse sendMessage(String botKey, MessagePayload payload) {
        String requestUrl = webhookBaseUrl + "/send?key=" + botKey;
        String jsonParams = gson.toJson(payload);
        Map<String, String> headers = new HashMap<>();
        String responseJson = SimpleHttpClient.sendJsonPost(requestUrl, jsonParams, headers);
        return gson.fromJson(responseJson, ApiResponse.class);
    }

    /**
     * JSON形式のメッセージ文字列を直接送信します。
     * @param botKey ボットのユニークな識別子
     * @param jsonMessage 送信するメッセージのJSON文字列
     * @return APIからの応答
     */
    @Override
    public ApiResponse sendMessage(String botKey, String jsonMessage) {
        String requestUrl = webhookBaseUrl + "/send?key=" + botKey;
        Map<String, String> headers = new HashMap<>();
        String responseJson = SimpleHttpClient.sendJsonPost(requestUrl, jsonMessage, headers);
        return gson.fromJson(responseJson, ApiResponse.class);
    }

    /**
     * ファイルまたは音声をアップロードします。
     * @param botKey ボットのユニークな識別子
     * @param mediaType ファイルの種類 ("file" または "voice")
     * @param file アップロードするファイル
     * @return アップロードAPIからの応答
     */
    @Override
    public MediaUploadResponse uploadMedia(String botKey, String mediaType, File file) {
        String requestUrl = webhookBaseUrl + "/upload_media?key=" + botKey + "&type=" + mediaType;
        HashMap<String, String> formParams = new HashMap<>();
        // フォームパラメータとしてkeyとtypeを送信する必要がある場合
        formParams.put("key", botKey);
        formParams.put("type", mediaType);

        HashMap<String, String> headers = new HashMap<>();
        String responseJson = SimpleHttpClient.sendMultipartPost(requestUrl, formParams, headers, file, "media");
        // 例: {"errcode":0,"errmsg":"ok","type":"file","media_id":"...", "created_at":"..."}
        return gson.fromJson(responseJson, MediaUploadResponse.class);
    }
}

これらのコンポーネントを組み合わせることで、Spring Bootアプリケーションから企業WeChatグループボットへの柔軟なメッセージ通知システムを構築できます。

タグ: Spring Boot 企業WeChat WeChat Work Webhook Java

6月29日 16:01 投稿