package kr.itn.itnhub.mattermost; import com.fasterxml.jackson.databind.JsonNode; import kr.itn.itnhub.config.MattermostProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; @Component public class MattermostRestClient implements MattermostClient { private final RestClient rest; private final String teamId; @Autowired public MattermostRestClient(MattermostProperties props) { this(props, RestClient.builder()); } /** * 전송 계층(transport)을 직접 지정할 수 있는 생성자. 운영 코드에서는 사용하지 않으며, * 테스트 등 별도의 {@link RestClient.Builder} 설정이 필요한 호출자를 위한 확장 지점이다. */ MattermostRestClient(MattermostProperties props, RestClient.Builder builder) { this.teamId = props.teamId(); this.rest = builder .baseUrl(props.baseUrl()) .defaultHeader("Authorization", "Bearer " + props.token()) .build(); } @Override public Optional findChannelIdByInternalName(String internalName) { try { JsonNode body = rest.get() .uri("/api/v4/teams/{teamId}/channels/name/{name}", teamId, internalName) .retrieve() .onStatus(status -> status.value() == 404, (req, res) -> { throw new NotFound(); }) .body(JsonNode.class); return idOf(body); } catch (NotFound e) { return Optional.empty(); } catch (RestClientException e) { throw new MattermostException("채널 조회 실패: " + internalName, e); } } /** 팀 채널 목록 조회 시 한 페이지에 요청하는 최대 개수. */ private static final int DISPLAY_NAME_PAGE_SIZE = 200; /** * 무한 루프 방지용 페이지 상한. VBA 원본의 안전장치를 그대로 옮긴 것으로, * 정상적인 팀이라면 도달할 일이 없다. */ private static final int DISPLAY_NAME_MAX_PAGES = 100; /** * 표시명으로 채널을 찾는다. {@code /users/me/teams/{teamId}/channels}는 토큰 사용자가 * "가입한" 채널만 돌려주므로 쓰지 않는다 - 이 조회는 레거시 채널(현재 내부명 규칙 이전에 * 만들어져 내부명으로는 못 찾는 채널)을 복구하기 위한 것인데, 토큰 사용자가 그 채널에 * 가입해 있지 않으면 조회가 누락되어 중복 채널이 생성된다. 대신 팀 전체 채널을 * {@code /teams/{teamId}/channels}로 페이지네이션하며 훑는다 - 이 엔드포인트는 비공개 * 채널까지 보려면 PAT가 system-admin 권한이어야 한다(README 참고). */ @Override public Optional findChannelIdByDisplayName(String displayName) { try { for (int page = 0; page < DISPLAY_NAME_MAX_PAGES; page++) { int currentPage = page; JsonNode channels = rest.get() .uri(uri -> uri.path("/api/v4/teams/{teamId}/channels") .queryParam("page", currentPage) .queryParam("per_page", DISPLAY_NAME_PAGE_SIZE) .build(teamId)) .retrieve() .body(JsonNode.class); if (channels == null || !channels.isArray()) { return Optional.empty(); } for (JsonNode channel : channels) { if (displayName.equals(channel.path("display_name").asText())) { return idOf(channel); } } if (channels.size() < DISPLAY_NAME_PAGE_SIZE) { return Optional.empty(); } } throw new MattermostException( "채널 목록 조회 페이지 한도(" + DISPLAY_NAME_MAX_PAGES + ") 초과: " + displayName); } catch (RestClientException e) { throw new MattermostException("채널 목록 조회 실패", e); } } @Override public String createPrivateChannel(String internalName, String displayName) { Map body = new LinkedHashMap<>(); body.put("team_id", teamId); body.put("name", internalName); body.put("display_name", displayName); body.put("type", "P"); body.put("purpose", ""); body.put("header", ""); try { JsonNode created = rest.post() .uri("/api/v4/channels") .contentType(MediaType.APPLICATION_JSON) .body(body) .retrieve() .body(JsonNode.class); return idOf(created).orElseThrow(() -> new MattermostException("채널 생성 응답에 id가 없습니다: " + displayName)); } catch (RestClientException e) { throw new MattermostException("채널 생성 실패: " + displayName, e); } } private Optional idOf(JsonNode node) { if (node == null) { return Optional.empty(); } String id = node.path("id").asText(""); return id.isBlank() ? Optional.empty() : Optional.of(id); } /** 404를 정상 흐름으로 되돌리기 위한 내부 신호. 밖으로 새지 않는다. */ private static final class NotFound extends RuntimeException { } }