|
1 | 1 | package test_with_remote_apis.methods; |
2 | 2 |
|
3 | 3 | import com.slack.api.Slack; |
| 4 | +import com.slack.api.methods.MethodsClient; |
4 | 5 | import com.slack.api.methods.SlackApiException; |
5 | 6 | import com.slack.api.methods.response.auth.AuthTestResponse; |
| 7 | +import com.slack.api.methods.response.chat.ChatGetPermalinkResponse; |
| 8 | +import com.slack.api.methods.response.chat.ChatPostMessageResponse; |
| 9 | +import com.slack.api.util.http.SlackHttpClient; |
| 10 | +import okhttp3.FormBody; |
| 11 | +import okhttp3.Response; |
6 | 12 | import com.slack.api.methods.response.slack_lists.SlackListsAccessDeleteResponse; |
7 | 13 | import com.slack.api.methods.response.slack_lists.SlackListsAccessSetResponse; |
8 | 14 | import com.slack.api.methods.response.slack_lists.SlackListsCreateResponse; |
@@ -388,4 +394,156 @@ public void itemsDeleteMultiple() throws IOException, SlackApiException { |
388 | 394 | assertThat(deleteMultipleResponse.isOk(), is(true)); |
389 | 395 | } |
390 | 396 |
|
| 397 | + // ------------------------------------------------------------------------ |
| 398 | + // EXPERIMENTAL — proof probe for the ListRecord.Field.message wire shape. |
| 399 | + // |
| 400 | + // Context: slackapi/java-slack-sdk#1587 reports that when a list item |
| 401 | + // carries a "message"-type field, the API can return `message` as an ARRAY |
| 402 | + // of message objects, which the production Gson path fails to deserialize |
| 403 | + // (JsonSyntaxException: Expected BEGIN_OBJECT but was BEGIN_ARRAY). The |
| 404 | + // docs (slackLists.items.create#field-types) show only the request shape |
| 405 | + // (an array of permalink URL strings) and give no response example, so the |
| 406 | + // response shape is unverified in public. This probe observes the real |
| 407 | + // production response shape empirically before we commit to a fix. |
| 408 | + // |
| 409 | + // It does two independent things: |
| 410 | + // 1) captures the RAW JSON of slackLists.items.info and asserts whether |
| 411 | + // the `message` field value is a JSON array `[` or an object `{`; |
| 412 | + // 2) attempts the TYPED deserialization via the production client and |
| 413 | + // records whether it throws on that real shape. |
| 414 | + // This method is intentionally standalone and does not modify any SDK |
| 415 | + // source; it is safe to revert wholesale. |
| 416 | + // ------------------------------------------------------------------------ |
| 417 | + @Test |
| 418 | + public void messageFieldArrayShapeProbe() throws IOException, SlackApiException { |
| 419 | + MethodsClient methods = slack.methods(); |
| 420 | + |
| 421 | + AuthTestResponse auth = methods.authTest(r -> r.token(botToken)); |
| 422 | + assertThat(auth.getError(), is(nullValue())); |
| 423 | + String teamHost = auth.getUrl(); // e.g. https://<team>.slack.com/ |
| 424 | + |
| 425 | + // 1) Post a real message so we have a genuine permalink to reference. |
| 426 | + ChatPostMessageResponse posted = methods.chatPostMessage(r -> r |
| 427 | + .token(botToken) |
| 428 | + .channel(channelId) |
| 429 | + .text("List message-field probe " + System.currentTimeMillis())); |
| 430 | + assertThat(posted.getError(), is(nullValue())); |
| 431 | + assertThat(posted.isOk(), is(true)); |
| 432 | + String messageTs = posted.getTs(); |
| 433 | + |
| 434 | + ChatGetPermalinkResponse permalinkResp = methods.chatGetPermalink(r -> r |
| 435 | + .token(botToken) |
| 436 | + .channel(channelId) |
| 437 | + .messageTs(messageTs)); |
| 438 | + assertThat(permalinkResp.getError(), is(nullValue())); |
| 439 | + String permalink = permalinkResp.getPermalink(); |
| 440 | + assertThat(permalink, is(notNullValue())); |
| 441 | + log.info("[probe] message permalink: {}", permalink); |
| 442 | + |
| 443 | + // 2) Create a list with a message-type column. |
| 444 | + ListColumn titleCol = ListColumn.builder() |
| 445 | + .key("title").name("Title").type("text").primaryColumn(true).build(); |
| 446 | + ListColumn messageCol = ListColumn.builder() |
| 447 | + .key("linked_message").name("Linked Message").type("message").build(); |
| 448 | + |
| 449 | + SlackListsCreateResponse createResponse = methods.slackListsCreate(r -> r |
| 450 | + .token(botToken) |
| 451 | + .name("Message-field probe list") |
| 452 | + .schema(Arrays.asList(titleCol, messageCol))); |
| 453 | + assertThat(createResponse.getError(), is(nullValue())); |
| 454 | + assertThat(createResponse.isOk(), is(true)); |
| 455 | + String listId = createResponse.getListId(); |
| 456 | + |
| 457 | + Map<String, String> keyToId = new HashMap<>(); |
| 458 | + createResponse.getListMetadata().getSchema().forEach(c -> keyToId.put(c.getKey(), c.getId())); |
| 459 | + String messageColId = keyToId.get("linked_message"); |
| 460 | + assertThat(messageColId, is(notNullValue())); |
| 461 | + |
| 462 | + // grant the bot's channel write access so items can be created |
| 463 | + methods.slackListsAccessSet(r -> r |
| 464 | + .token(botToken).listId(listId).accessLevel("write") |
| 465 | + .channelIds(Arrays.asList(channelId))); |
| 466 | + |
| 467 | + // 3) Create an item with a primary text field. The message field is |
| 468 | + // set separately below via items.update using the docs-correct request |
| 469 | + // shape (an array of message permalink URL strings), sent as a raw form |
| 470 | + // param so the request carries exactly `message: ["<permalink>"]`. |
| 471 | + SlackListsItemsCreateResponse createItemResponse = methods.slackListsItemsCreate(r -> r |
| 472 | + .token(botToken) |
| 473 | + .listId(listId) |
| 474 | + .initialFields(Arrays.asList( |
| 475 | + ListRecord.Field.builder().columnId(keyToId.get("title")).text("probe row").build() |
| 476 | + ))); |
| 477 | + assertThat(createItemResponse.getError(), is(nullValue())); |
| 478 | + assertThat(createItemResponse.isOk(), is(true)); |
| 479 | + String itemId = createItemResponse.getItem().getId(); |
| 480 | + |
| 481 | + // Set the message field on the row via items.update (request = URL array). |
| 482 | + // Sent as a raw form param so the request carries the exact docs shape. |
| 483 | + SlackHttpClient http = slack.getHttpClient(); |
| 484 | + FormBody updateForm = new FormBody.Builder() |
| 485 | + .add("list_id", listId) |
| 486 | + .add("cells", "[{\"row_id\":\"" + itemId + "\",\"column_id\":\"" + messageColId |
| 487 | + + "\",\"message\":[\"" + permalink + "\"]}]") |
| 488 | + .build(); |
| 489 | + try (Response updRaw = http.postFormWithBearerHeader( |
| 490 | + MethodsClient.ENDPOINT_URL_PREFIX + "slackLists.items.update", botToken, updateForm)) { |
| 491 | + String updBody = updRaw.body() != null ? updRaw.body().string() : ""; |
| 492 | + log.info("[probe] items.update raw ok? contains \\\"ok\\\":true -> {}", updBody.contains("\"ok\":true")); |
| 493 | + } |
| 494 | + |
| 495 | + // 4) RAW-capture the read-back shape of the message field. |
| 496 | + FormBody infoForm = new FormBody.Builder() |
| 497 | + .add("list_id", listId) |
| 498 | + .add("id", itemId) |
| 499 | + .build(); |
| 500 | + String rawBody; |
| 501 | + try (Response infoRaw = http.postFormWithBearerHeader( |
| 502 | + MethodsClient.ENDPOINT_URL_PREFIX + "slackLists.items.info", botToken, infoForm)) { |
| 503 | + rawBody = infoRaw.body() != null ? infoRaw.body().string() : ""; |
| 504 | + } |
| 505 | + log.info("[probe] raw items.info body (truncated 4k): {}", |
| 506 | + rawBody.length() > 4000 ? rawBody.substring(0, 4000) : rawBody); |
| 507 | + |
| 508 | + int msgIdx = rawBody.indexOf("\"message\""); |
| 509 | + assertThat("expected a message field in the raw response", msgIdx, is(not(-1))); |
| 510 | + // Skip past `"message"` and any whitespace / colon to the value's first char. |
| 511 | + int valStart = msgIdx + "\"message\"".length(); |
| 512 | + while (valStart < rawBody.length() |
| 513 | + && (rawBody.charAt(valStart) == ':' || Character.isWhitespace(rawBody.charAt(valStart)))) { |
| 514 | + valStart++; |
| 515 | + } |
| 516 | + char firstValChar = rawBody.charAt(valStart); |
| 517 | + boolean isArray = firstValChar == '['; |
| 518 | + boolean isObject = firstValChar == '{'; |
| 519 | + log.info("[probe] message field first value char = '{}' => isArray={}, isObject={}", |
| 520 | + firstValChar, isArray, isObject); |
| 521 | + |
| 522 | + // 5) Probe TYPED (production Gson) deserialization on this real shape. |
| 523 | + boolean typedThrew = false; |
| 524 | + String typedError = null; |
| 525 | + try { |
| 526 | + SlackListsItemsInfoResponse typed = methods.slackListsItemsInfo(r -> r |
| 527 | + .token(botToken).listId(listId).id(itemId)); |
| 528 | + log.info("[probe] typed items.info deserialized ok={}, error={}", |
| 529 | + typed.isOk(), typed.getError()); |
| 530 | + } catch (Exception e) { |
| 531 | + typedThrew = true; |
| 532 | + typedError = e.getClass().getSimpleName() + ": " + e.getMessage(); |
| 533 | + log.warn("[probe] typed items.info THREW during deserialization: {}", typedError); |
| 534 | + } |
| 535 | + log.info("[probe] SUMMARY host={} messageIsArray={} messageIsObject={} typedDeserializationThrew={} typedError={}", |
| 536 | + teamHost, isArray, isObject, typedThrew, typedError); |
| 537 | + |
| 538 | + // The point of the probe is observation, not a pass/fail gate on the |
| 539 | + // shape. We only assert the response actually carried a message value |
| 540 | + // (either shape), and log the empirical result for the issue. |
| 541 | + assertThat("message value should be an array or object", isArray || isObject, is(true)); |
| 542 | + |
| 543 | + // cleanup |
| 544 | + methods.slackListsItemsDelete(r -> r.token(botToken).listId(listId).id(itemId)); |
| 545 | + methods.slackListsAccessDelete(r -> r.token(botToken).listId(listId) |
| 546 | + .channelIds(Arrays.asList(channelId))); |
| 547 | + } |
| 548 | + |
391 | 549 | } |
0 commit comments