File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
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.util.InvalidMimeTypeException;
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.UUID;
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<String, String> 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<String> 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<String> 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<String, String> 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<PostView> 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<PostView> 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<FileView> collectChannelFiles(String channelId) {
List<FileView> 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 static final String MULTIPART_CRLF = "\r\n";
/**
* multipart/form-data 본문을 직접 바이트로 구성해서 보낸다. {@code MultipartBodyBuilder}는
* 편리하지만 이 프로젝트의 클래스패스에는 없는 {@code org.reactivestreams.Publisher}를
* 내부적으로 필요로 해(RestClient의 multipart 기록 경로가 파트마다 Publisher인지 검사한다)
* NoClassDefFoundError가 난다 - 새 의존성을 추가하는 대신 바이트를 직접 조립한다.
* 한글 파일명도 헤더 문자열 인코딩 제약(ISO-8859-1 등) 없이 UTF-8 바이트로 그대로 실린다.
*/
@Override
public String uploadFile(String channelId, String filename, byte[] data, String contentType) {
String boundary = "----ItnHubBoundary" + UUID.randomUUID();
try {
byte[] body = buildMultipartBody(boundary, channelId, filename, data, contentType);
JsonNode responseBody = rest.post()
.uri("/api/v4/files")
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.body(body)
.retrieve()
.body(JsonNode.class);
if (responseBody == null) {
throw new MattermostException("파일 업로드 응답이 없습니다: " + filename);
}
JsonNode fileInfos = responseBody.path("file_infos");
if (!fileInfos.isArray() || fileInfos.isEmpty()) {
throw new MattermostException("파일 업로드 응답에 file_infos가 없습니다: " + filename);
}
return idOf(fileInfos.get(0)).orElseThrow(() ->
new MattermostException("파일 업로드 응답에 id가 없습니다: " + filename));
} catch (RestClientException e) {
throw new MattermostException("파일 업로드 실패: " + filename, e);
}
}
private byte[] buildMultipartBody(String boundary, String channelId, String filename,
byte[] data, String contentType) {
try {
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
writeAscii(out, "--" + boundary + MULTIPART_CRLF);
writeAscii(out, "Content-Disposition: form-data; name=\"channel_id\"" + MULTIPART_CRLF + MULTIPART_CRLF);
out.write(channelId.getBytes(java.nio.charset.StandardCharsets.UTF_8));
writeAscii(out, MULTIPART_CRLF);
writeAscii(out, "--" + boundary + MULTIPART_CRLF);
writeAscii(out, "Content-Disposition: form-data; name=\"files\"; filename=\""
+ filename + "\"" + MULTIPART_CRLF);
writeAscii(out, "Content-Type: " + resolveContentType(contentType) + MULTIPART_CRLF + MULTIPART_CRLF);
out.write(data);
writeAscii(out, MULTIPART_CRLF);
writeAscii(out, "--" + boundary + "--" + MULTIPART_CRLF);
return out.toByteArray();
} catch (java.io.IOException e) {
throw new MattermostException("파일 업로드 본문 구성 실패: " + filename, e);
}
}
private void writeAscii(java.io.ByteArrayOutputStream out, String text) throws java.io.IOException {
out.write(text.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
private String resolveContentType(String contentType) {
if (contentType == null || contentType.isBlank()) {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
try {
return MediaType.parseMediaType(contentType).toString();
} catch (InvalidMimeTypeException e) {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
}
@Override
public String createPost(String channelId, String message, List<String> fileIds) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("channel_id", channelId);
body.put("message", message == null ? "" : message);
if (fileIds != null && !fileIds.isEmpty()) {
body.put("file_ids", fileIds);
}
try {
JsonNode created = rest.post()
.uri("/api/v4/posts")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(JsonNode.class);
return idOf(created).orElseThrow(() ->
new MattermostException("게시글 생성 응답에 id가 없습니다: " + channelId));
} catch (RestClientException e) {
throw new MattermostException("게시글 생성 실패: " + channelId, e);
}
}
@Override
public void pinPost(String postId) {
try {
rest.post()
.uri("/api/v4/posts/{postId}/pin", postId)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of())
.retrieve()
.toBodilessEntity();
} catch (RestClientException e) {
throw new MattermostException("게시글 고정 실패: " + postId, e);
}
}
@Override
public void unpinPost(String postId) {
try {
rest.post()
.uri("/api/v4/posts/{postId}/unpin", postId)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of())
.retrieve()
.toBodilessEntity();
} catch (RestClientException e) {
throw new MattermostException("게시글 고정 해제 실패: " + postId, e);
}
}
@Override
public List<PostView> getPinnedPosts(String channelId) {
try {
JsonNode body = rest.get()
.uri("/api/v4/channels/{channelId}/pinned", channelId)
.retrieve()
.body(JsonNode.class);
if (body == null) {
return List.of();
}
List<PostView> result = new ArrayList<>();
JsonNode posts = body.path("posts");
// Mattermost가 이미 newest-first(order)로 내려주므로 뒤집지 않는다.
for (JsonNode idNode : body.path("order")) {
result.add(toPostView(posts.path(idNode.asText())));
}
return result;
} catch (RestClientException e) {
throw new MattermostException("고정 게시글 조회 실패: " + channelId, e);
}
}
@Override
public void updateChannelHeader(String channelId, String header) {
Map<String, String> body = new LinkedHashMap<>();
body.put("header", header == null ? "" : header);
try {
rest.put()
.uri("/api/v4/channels/{channelId}/patch", channelId)
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.toBodilessEntity();
} catch (RestClientException e) {
throw new MattermostException("채널 헤더 변경 실패: " + channelId, 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<FileRef> 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);
}
/**
* 표시 이름 우선순위는 Mattermost 화면과 같게 맞춘다:
* 닉네임 → 성명(first+last) → 계정명(username) → id.
* (계정명만 쓰면 화면에 itnadmin처럼 나와 실제 Mattermost 표시와 어긋난다.)
* 실패해도 원본 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 fullName = (user.path("first_name").asText("") + " "
+ user.path("last_name").asText("")).trim();
if (!fullName.isBlank()) {
return fullName;
}
String username = user.path("username").asText("");
return username.isBlank() ? id : username;
} catch (RestClientException e) {
return id;
}
});
}
private Optional<String> 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 {
}
}