Google Chat API服务账号上传附件遇403:不支持应用认证
Google Chat API 附件上传403问题排查与解答
问题描述
使用服务账号集成Google Chat API时,纯文本消息发送正常,但调用attachments:upload接口上传附件时收到403错误,提示该方法不支持应用认证。
可行操作
服务账号发送纯文本消息的代码可正常运行:
public JSONObject sendMessage(String spaceName, String text) { JSONObject body = new JSONObject(); body.put("text", text); return post(chatBase + spaceName + "/messages", body.toString(), getAccessToken()); }
失败场景
调用可恢复上传API上传附件时的错误信息:
ai.knolli.googleChat.services.GoogleChatService$GoogleChatException: uploadAttachment initiation failed [HTTP 403]: { "error": { "code": 403, "message": "This method doesn't support app authentication with a service account. Authenticate with a user account.", "status": "PERMISSION_DENIED", "details": [ { "@type": "type.googleapis.com/google.rpc.Help", "links": [ { "description": "See article Authenticate as a user.", "url": "https://developers.google.com/chat/api/guides/auth/users" } ] } ] } }
完整代码
@Service public class GoogleChatService { @Value("${google.chat.service-account-path}") private String serviceAccountPath; @Value("${google.chat.api-base:https://chat.googleapis.com/v1/}") private String chatBase; @Value("${google.chat.impersonated-user:}") private String impersonatedUser; private static final List<String> SCOPES = List.of( "https://www.googleapis.com/auth/chat.bot", "https://www.googleapis.com/auth/chat.messages", "https://www.googleapis.com/auth/chat.messages.create", "https://www.googleapis.com/auth/chat.spaces", "https://www.googleapis.com/auth/chat.spaces.readonly", "https://www.googleapis.com/auth/chat.app.memberships", "https://www.googleapis.com/auth/chat.spaces.create", "https://www.googleapis.com/auth/chat.memberships.app" ); private HttpClient httpClient; private GoogleCredentials baseCredentials; @PostConstruct public void init() { this.httpClient = HttpClient.newHttpClient(); try (InputStream in = new FileInputStream(serviceAccountPath)) { this.baseCredentials = GoogleCredentials.fromStream(in); } catch (Exception e) { throw new GoogleChatException("Failed to load service account from: " + serviceAccountPath, e); } } private String getAccessToken() { try { GoogleCredentials creds = baseCredentials.createScoped(SCOPES); if (impersonatedUser != null && !impersonatedUser.isBlank()) { creds = creds.createDelegated(impersonatedUser); } creds.refreshIfExpired(); return creds.getAccessToken().getTokenValue(); } catch (Exception e) { throw new GoogleChatException("Failed to get access token", e); } } public JSONObject sendMessage(String spaceName, String text) { JSONObject body = new JSONObject(); body.put("text", text); return post(chatBase + spaceName + "/messages", body.toString(), getAccessToken()); } public String uploadAttachment(String spaceName, File file) { String token = getAccessToken(); String mimeType = detectMimeType(file.getName()); long fileSize = file.length(); String initiateUrl = chatBase.replace("/v1/", "/upload/v1/") + spaceName + "/attachments:upload?uploadType=resumable"; try { HttpRequest initiateRequest = HttpRequest.newBuilder() .uri(URI.create(initiateUrl)) .header("Authorization", "Bearer " + token) .header("Content-Type", "application/json") .header("X-Goog-Upload-Protocol", "resumable") .header("X-Goog-Upload-Command", "start") .header("X-Goog-Upload-Header-Content-Length", String.valueOf(fileSize)) .header("X-Goog-Upload-Header-Content-Type", mimeType) .POST(HttpRequest.BodyPublishers.ofString( new JSONObject().put("filename", file.getName()).toString())) .build(); HttpResponse<String> initiateResponse = httpClient.send(initiateRequest, HttpResponse.BodyHandlers.ofString()); if (initiateResponse.statusCode() != 200) { throw new GoogleChatException("uploadAttachment initiation failed [HTTP " + initiateResponse.statusCode() + "]: " + initiateResponse.body()); } String uploadUrl = initiateResponse.headers() .firstValue("x-goog-upload-url") .orElseThrow(); HttpRequest uploadRequest = HttpRequest.newBuilder() .uri(URI.create(uploadUrl)) .header("Content-Type", mimeType) .header("X-Goog-Upload-Offset", "0") .header("X-Goog-Upload-Command", "upload, finalize") .POST(HttpRequest.BodyPublishers.ofByteArray(readAllBytes(file))) .build(); HttpResponse<String> uploadResponse = httpClient.send(uploadRequest, HttpResponse.BodyHandlers.ofString()); return new JSONObject(uploadResponse.body()) .getJSONObject("attachmentDataRef") .getString("attachmentUploadToken"); } catch (Exception e) { throw new GoogleChatException("uploadAttachment failed", e); } } public JSONObject sendMessageWithAttachment(String spaceName, String text, File file) { String uploadToken = uploadAttachment(spaceName, file); JSONObject attachmentDataRef = new JSONObject(); attachmentDataRef.put("attachmentUploadToken", uploadToken); JSONObject attachment = new JSONObject(); attachment.put("attachmentDataRef", attachmentDataRef); JSONArray attachments = new JSONArray(); attachments.put(attachment); JSONObject body = new JSONObject(); body.put("text", text); body.put("attachment", attachments); return post(chatBase + spaceName + "/messages", body.toString(), getAccessToken()); } private JSONObject post(String url, String jsonBody, String token) { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .header("Authorization", "Bearer " + token) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); return new JSONObject(response.body()); } catch (Exception e) { throw new GoogleChatException("POST failed", e); } } private static String detectMimeType(String fileName) { return "application/octet-stream"; } private static byte[] readAllBytes(File file) throws IOException { try (InputStream in = new FileInputStream(file)) { return in.readAllBytes(); } } public static class GoogleChatException extends RuntimeException { public GoogleChatException(String message, Throwable cause) { super(message, cause); } } }
技术问询与解答
问询1:在Google Chat中,是否无法使用service account(机器人认证)上传附件?
是的,Google Chat的attachments:upload接口明确不支持服务账号的机器人认证模式,仅允许通过用户身份认证调用。
问询2:上传附件是否必须使用用户OAuth(或带impersonation的全域委派)?
对,必须使用用户OAuth2认证,或者通过服务账号配置全域委派来模拟Google Workspace中的特定用户身份(即代码中impersonatedUser配置的账号)完成上传操作。
问询3:若上述问题答案为是,上传附件需要哪些具体的SCOPES?
完成附件上传与带附件消息发送,仅需https://www.googleapis.com/auth/chat.messages.create权限范围即可;如果需要管理消息的其他操作,也可以保留https://www.googleapis.com/auth/chat.messages。
问询4:是否存在通过机器人发送文件的替代方案?
有两种可行的替代方案:
- 服务账号模拟用户:确保服务账号已在Google Workspace后台配置全域委派权限,且
impersonatedUser为Workspace内的有效用户,同时该用户拥有目标Chat空间的访问权限,即可用模拟身份完成附件上传。 - Drive链接间接发送:将文件上传至Google Drive并生成共享链接,然后用服务账号发送包含该链接的纯文本消息,用户点击链接即可访问文件。
内容的提问来源于stack exchange,提问作者Gaurav Kumar
相关产品推荐
相关产品推荐

