package kr.itn.itnhub.mattermost; import com.fasterxml.jackson.databind.JsonNode; import kr.itn.itnhub.config.MattermostProperties; import kr.itn.itnhub.feed.FileRef; import kr.itn.itnhub.feed.FileView; import kr.itn.itnhub.feed.PostView; 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.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @Component public class MattermostRestClient implements MattermostClient { private final RestClient rest; private final String teamId; /** * 사용자 id → 표시용 이름(닉네임 우선, 없으면 아이디) 캐시. 게시물/자료 목록 조회 시 * 같은 몇 명이 계속 반복해서 등장하므로, 매 게시글마다 {@code /users/{id}}를 다시 * 부르지 않고 여기서 재사용한다. 조회 실패도 원본 id로 캐시해 전체 목록 조회가 * 실패하지 않게 한다. */ private final Map userNameCache = new ConcurrentHashMap<>(); @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); } } /** {@code collectChannelFiles} 페이지네이션에 쓰는 페이지당 게시글 수. */ private static final int FILES_PAGE_SIZE = 200; /** {@code findChannelIdByDisplayName}과 같은 이유의 무한 루프 방지 상한. */ private static final int FILES_MAX_PAGES = 100; @Override public List getRecentPosts(String channelId, int perPage) { try { JsonNode body = rest.get() .uri(uri -> uri.path("/api/v4/channels/{channelId}/posts") .queryParam("per_page", perPage) .build(channelId)) .retrieve() .body(JsonNode.class); if (body == null) { return List.of(); } List result = new ArrayList<>(); JsonNode posts = body.path("posts"); for (JsonNode idNode : body.path("order")) { result.add(toPostView(posts.path(idNode.asText()))); } // Mattermost는 newest-first로 내려주지만 화면은 오래된순으로 보여준다. Collections.reverse(result); return result; } catch (RestClientException e) { throw new MattermostException("게시글 조회 실패: " + channelId, e); } } @Override public List collectChannelFiles(String channelId) { List files = new ArrayList<>(); try { for (int page = 0; page < FILES_MAX_PAGES; page++) { int currentPage = page; JsonNode body = rest.get() .uri(uri -> uri.path("/api/v4/channels/{channelId}/posts") .queryParam("per_page", FILES_PAGE_SIZE) .queryParam("page", currentPage) .build(channelId)) .retrieve() .body(JsonNode.class); if (body == null) { return files; } JsonNode order = body.path("order"); JsonNode posts = body.path("posts"); int count = 0; for (JsonNode idNode : order) { count++; JsonNode post = posts.path(idNode.asText()); long createAt = post.path("create_at").asLong(); String uploader = resolveUserName(post.path("user_id").asText()); for (JsonNode f : post.path("metadata").path("files")) { files.add(new FileView( f.path("id").asText(), f.path("name").asText(), f.path("size").asLong(), f.path("mime_type").asText(""), createAt, uploader)); } } if (count < FILES_PAGE_SIZE) { return files; } } throw new MattermostException( "채널 파일 목록 조회 페이지 한도(" + FILES_MAX_PAGES + ") 초과: " + channelId); } catch (RestClientException e) { throw new MattermostException("채널 파일 목록 조회 실패: " + channelId, e); } } @Override public byte[] downloadFile(String fileId) { try { byte[] body = rest.get() .uri("/api/v4/files/{id}", fileId) .retrieve() .body(byte[].class); return body == null ? new byte[0] : body; } catch (RestClientException e) { throw new MattermostException("파일 다운로드 실패: " + fileId, e); } } @Override public FileRef fileInfo(String fileId) { try { JsonNode body = rest.get() .uri("/api/v4/files/{id}/info", fileId) .retrieve() .body(JsonNode.class); if (body == null) { throw new MattermostException("파일 정보 조회 실패: " + fileId); } return new FileRef(fileId, body.path("name").asText(""), body.path("size").asLong(), body.path("mime_type").asText("")); } catch (RestClientException e) { throw new MattermostException("파일 정보 조회 실패: " + fileId, e); } } private PostView toPostView(JsonNode post) { String id = post.path("id").asText(); String message = post.path("message").asText(""); long createAt = post.path("create_at").asLong(); boolean system = post.path("type").asText("").startsWith("system_"); List files = new ArrayList<>(); for (JsonNode f : post.path("metadata").path("files")) { files.add(new FileRef( f.path("id").asText(), f.path("name").asText(), f.path("size").asLong(), f.path("mime_type").asText(""))); } return new PostView(id, resolveUserName(post.path("user_id").asText()), message, createAt, system, files); } /** 실패해도 원본 id로 캐시해, 사용자 조회 실패 하나 때문에 목록 전체가 죽지 않게 한다. */ private String resolveUserName(String userId) { return userNameCache.computeIfAbsent(userId, id -> { try { JsonNode user = rest.get() .uri("/api/v4/users/{id}", id) .retrieve() .body(JsonNode.class); if (user == null) { return id; } String nickname = user.path("nickname").asText(""); if (!nickname.isBlank()) { return nickname; } String username = user.path("username").asText(""); return username.isBlank() ? id : username; } catch (RestClientException e) { return id; } }); } 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 { } }