mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
support for 202507 endpoints (#37)
* [auth][api] accept the 20250424.01 client * [2025] unstable * 20250718.0 * correct one this time * stubs * more stubs * more stubs * [lists] add worker * [ai] route stubs * [api] player photo setting * [econ] add roomEconConfig route * [infra] update worker generators * [worker] add cards/moderation/platformnotification workers * [lists] updates to some endpoints * [clubs] stub out announcement endpoint, for now * [econ] stub out season endpoints for now * [chat] apps/chat stub out party endpoint not sure the shape yet * [api] stub out statsig and lockeditems * [doc] new services * [lists] stub the bulk endpoint * [datacollection] add placeholder service until we can kill it * [api] set gifting to lvl5 * update lock * [cdn] enable cache * [match] matchmake v2 * [lists] stub some lists * [ai] stubs * [rooms] new subroom save endpoint * [econ] add bulk purchase endpoint * [discovery] update featured creator to 1 for fun * [api] add photo settings flag * [chat] fixup chat permissions (sorta) * [auth] restrictions endpoint * [rooms] contributed endpoint * [api] fix outfit endpoint * [discovery] attempt to fix store * [chat] privacy endpoints * [api] cheered images * [rooms] add xp endpoint (disbaled) * [rooms] add xp endpoint (disabled) * update images-db for cheers * [rooms] add autocomplete endpoint * [cdn/img] increase cache ttl for statics * [api] bulk route for images * [accounts] add banner image * [api] add misc missing endpoints * [discovery] remove AI tab * [platformnotifications] stub some endpoints * [lists] add some more lists * [rooms] additional endpoints * [chat] stub a few privacy endpoints * [econ] stub some endpoints * misc db fixes * [api] tweak shape for images v6 * [rooms] dont show trending RROs
This commit is contained in:
+185
-2
@@ -10,6 +10,7 @@ import { getThreadMessages } from './message-db'
|
||||
import {
|
||||
AUTHED,
|
||||
ChatMessageDto,
|
||||
ChatPrivacySettings,
|
||||
ChatResult,
|
||||
ChatThreadDto,
|
||||
ChatThreadWithMessagesDto,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
json,
|
||||
messageCountParam,
|
||||
NOT_A_MEMBER_RESPONSE,
|
||||
PartyInviteSettings,
|
||||
PartyThread,
|
||||
RenameThreadRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
@@ -85,14 +88,34 @@ async function formMessageCount(c: Context<App>, fallback: number): Promise<numb
|
||||
}
|
||||
|
||||
/**
|
||||
* What a chat action reports back to the client alongside its payload — the reference's
|
||||
* ChatResult. Only success and "bad arguments" are reachable here.
|
||||
* What a chat action reports back to the client — the reference's ChatResult, usually
|
||||
* alongside a payload but sometimes (the DM privacy check) as the whole body. Only these
|
||||
* four of the enum's twenty values are reachable here; `ChatResult` in openapi.ts records
|
||||
* the rest, including the 15/16 privacy refusals nothing on this server can answer.
|
||||
*/
|
||||
const CHAT_SUCCESS = 0
|
||||
const CHAT_INVALID_ARGUMENTS = 1
|
||||
const CHAT_MEMBERSHIP_NOT_FOUND = 3
|
||||
const CHAT_PLAYER_ALREADY_ON_THREAD = 4
|
||||
|
||||
/**
|
||||
* How long a party invite link stays usable, in minutes (`GET /settings/partyinvite`).
|
||||
* The reference's value. Nothing here stores invite links, so this is what the client
|
||||
* counts down with rather than a lifetime this server enforces.
|
||||
*/
|
||||
const PARTY_INVITE_LIFETIME_MINUTES = 60
|
||||
|
||||
/**
|
||||
* Who may start a chat with a player — the client's `ChatPrivacy` enum, served numerically
|
||||
* like every other enum on this build. `Friends` is what a fresh account reports, and what
|
||||
* every account reports here: nothing stores a per-player setting yet.
|
||||
*/
|
||||
const ChatPrivacy = {
|
||||
Friends: 0,
|
||||
Favorites: 1,
|
||||
NoOne: 2,
|
||||
} as const
|
||||
|
||||
/** The hub is a single global Durable Object instance, as every worker addresses it. */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
@@ -426,6 +449,166 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// How long a party invite link lives. Server-side config the client reads to stamp its
|
||||
// own invite links, not per-player state — one hour, which is the reference's value.
|
||||
//
|
||||
// A bare single-key object: `{ InviteLinkLifetimeInMinutes }` and nothing else, no
|
||||
// `{ success, error, value }` envelope. Nothing here expires links (there is no invite
|
||||
// link store), so this is the number the client shows and counts down with rather than
|
||||
// a lifetime this server enforces.
|
||||
.get(
|
||||
'/settings/partyinvite',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'Party invite settings',
|
||||
description: [
|
||||
'How long a party invite link stays usable, in minutes, as a bare single-key object —',
|
||||
'no envelope. 60 here, the reference’s value. Nothing on this server stores or expires',
|
||||
'invite links, so the client is the only thing that acts on it.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(PartyInviteSettings, 'The invite-link lifetime'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
return c.json({ InviteLinkLifetimeInMinutes: PARTY_INVITE_LIFETIME_MINUTES })
|
||||
}
|
||||
)
|
||||
|
||||
// The party thread (`/thread/party?maxCount=1&mode=0`). STUB: the response shape is
|
||||
// unknown — it hasn't been observed off a live client — so this answers an empty
|
||||
// object, which parses as "no party" rather than failing the client's deserializer the
|
||||
// way a 404 or a bare array would. `maxCount` and `mode` are accepted and ignored.
|
||||
// Replace the body once the real shape is captured.
|
||||
.get(
|
||||
'/thread/party',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'The caller’s party thread (stub)',
|
||||
description: [
|
||||
'STUB — the response shape has not been observed off a live client, so this answers an',
|
||||
'empty object `{}`, which parses as "no party" rather than failing the client’s',
|
||||
'deserializer the way a 404 or a bare array would. `maxCount` and `mode` are accepted and',
|
||||
'ignored. Replace the body once the real shape is captured.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'maxCount',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Page size the client sends (1). Ignored by the stub',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
{
|
||||
name: 'mode',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Unknown mode selector the client sends (0). Ignored by the stub',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(PartyThread, 'Always `{}` — the stub carries no party'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
return c.json({})
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's chat privacy settings — who may DM them, and who may pull them into a
|
||||
// group chat. Both report `Friends`, which is the reference's default and the safer of
|
||||
// the two directions to be wrong in: it describes a player as more private than the
|
||||
// server actually enforces, rather than less.
|
||||
//
|
||||
// REPORTED, NOT ENFORCED. Nothing here stores a per-player setting or checks one — the
|
||||
// DM check below allows every message regardless — so this is what the client renders on
|
||||
// its privacy screen. Wire the two together if this ever becomes real: a screen that says
|
||||
// "Friends" while anyone can message you is worse than one that says nothing.
|
||||
//
|
||||
// `playerId` comes off the TOKEN, not a query param: the answer is about the caller.
|
||||
.get(
|
||||
'/thread/chatPrivacySetting',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'The caller’s chat privacy settings',
|
||||
description: [
|
||||
'Who may direct-message the caller and who may add them to a group chat, as the',
|
||||
'`ChatPrivacy` enum by NUMBER (0 Friends · 1 Favorites · 2 NoOne). Both are `Friends`',
|
||||
'here — nothing stores a per-player setting — and nothing enforces them either: the',
|
||||
'DM check allows every message. `playerId` is the caller, read from the token.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(ChatPrivacySettings, 'The caller’s settings — always Friends/Friends'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
return c.json({
|
||||
playerId: id,
|
||||
directMessagePrivacySetting: ChatPrivacy.Friends,
|
||||
groupChatPrivacySetting: ChatPrivacy.Friends,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// May the caller DM this player? Asked before the client opens a new direct message, so
|
||||
// it can grey the button out rather than let the send fail. Always 0 (Success): nothing
|
||||
// here stores the who-can-message-me privacy setting the name refers to, so there is no
|
||||
// setting to refuse on.
|
||||
//
|
||||
// The body is a bare ChatResult INTEGER — the client instantiates its response wrapper
|
||||
// with the ChatResult enum, not a bool, so `true` decodes as nothing. The refusals this
|
||||
// endpoint would otherwise answer are 15 (blocked by the caller's own privacy setting)
|
||||
// and 16 (blocked by the other player's); everything else in the enum belongs to the
|
||||
// thread actions. It is served numerically: this client build carries no by-name enum
|
||||
// formatter.
|
||||
.get(
|
||||
'/thread/checkCanSendDirectMessageWithPrivacySetting',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'May the caller DM this player?',
|
||||
description: [
|
||||
'Whether the caller may open a direct message with `receivingPlayerId`, as a bare',
|
||||
'ChatResult integer — 0 (Success) means allowed; a real refusal would be 15 (the',
|
||||
'caller’s own privacy setting) or 16 (the other player’s). Always 0 here: this server',
|
||||
'stores no who-can-message-me privacy setting, so there is nothing to refuse on.',
|
||||
'`receivingPlayerId` is accepted and ignored; the answer is the same for every player,',
|
||||
'and the client asks again for the next one.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'receivingPlayerId',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'The player the caller wants to message. Accepted and ignored.',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(ChatResult, 'Always 0 (Success) — the DM is allowed'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
)
|
||||
|
||||
// "Open the chat with these people" — the client's GetChatBetweenPlayers. Fetch or
|
||||
// create: the thread whose membership is exactly `ids` plus the caller, opened only
|
||||
// if they don't already share one. Returning a fresh empty thread each call would
|
||||
|
||||
@@ -110,13 +110,29 @@ export const ChatThreadWithMessagesDto = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* The bare ChatResult integer several actions answer with (HTTP 200 either way): 0
|
||||
* success, 1 invalid arguments, 3 membership not found (which doubles as "no such
|
||||
* thread"), 4 player already on the thread.
|
||||
* The bare ChatResult integer several actions answer with (HTTP 200 either way). The
|
||||
* client's enum in full, recovered from the build — it is served NUMERICALLY, there being
|
||||
* no by-name formatter on the client side:
|
||||
*
|
||||
* 0 Success · 1 InvalidArguments · 2 ThreadNotFound · 3 MembershipNotFound ·
|
||||
* 4 PlayerAlreadyOnThread · 5 CannotMessagePlayer · 6 InvalidCharacters ·
|
||||
* 7 RecentlyLeftThread · 8 ThreadTooLarge · 9 InsufficientPermission ·
|
||||
* 10 TooManyAffiliationThreads · 11 UnderModeration · 12 MessageNotFound ·
|
||||
* 13 InvalidThreadJoinType · 14 PlayerBanned ·
|
||||
* 15 CannotMessagePlayerDueToLocalPrivacySetting ·
|
||||
* 16 CannotMessagePlayerDueToRemotePrivacySetting ·
|
||||
* 17 SuccessWithPartialPlayersAddedToThreadDueToPrivacySetting ·
|
||||
* 18 CannotAddPlayersToThreadDueToPrivacySetting ·
|
||||
* 19 CannotConvertDirectMessageChatToGroupChatDueToPrivacySetting
|
||||
*
|
||||
* Only 0, 1, 3 and 4 are reachable on this server; the rest are recorded so a route that
|
||||
* needs one answers the number the client actually branches on.
|
||||
*/
|
||||
export const ChatResult = z
|
||||
.int()
|
||||
.describe('0 success · 1 invalid arguments · 3 membership not found · 4 already on thread')
|
||||
.describe(
|
||||
'ChatResult, numeric: 0 Success · 1 InvalidArguments · 2 ThreadNotFound · 3 MembershipNotFound · 4 PlayerAlreadyOnThread · 5 CannotMessagePlayer · 6 InvalidCharacters · 7 RecentlyLeftThread · 8 ThreadTooLarge · 9 InsufficientPermission · 10 TooManyAffiliationThreads · 11 UnderModeration · 12 MessageNotFound · 13 InvalidThreadJoinType · 14 PlayerBanned · 15 CannotMessagePlayerDueToLocalPrivacySetting · 16 CannotMessagePlayerDueToRemotePrivacySetting · 17 SuccessWithPartialPlayersAddedToThreadDueToPrivacySetting · 18 CannotAddPlayersToThreadDueToPrivacySetting · 19 CannotConvertDirectMessageChatToGroupChatDueToPrivacySetting'
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /thread` — the reference's wrapper: the created (or resolved) thread plus the
|
||||
@@ -138,6 +154,43 @@ export const SendMessageResponse = z.object({
|
||||
chatThread: ChatThreadWithMessagesDto.nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /settings/partyinvite` — how long a party invite link stays usable, in minutes. A
|
||||
* bare single-key object, not an envelope: the whole body is this one setting.
|
||||
*/
|
||||
export const PartyInviteSettings = z.object({
|
||||
InviteLinkLifetimeInMinutes: z
|
||||
.int()
|
||||
.describe('Minutes a party invite link stays valid before it lapses'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /thread/chatPrivacySetting` — who may start a chat with the caller. camelCase, unlike
|
||||
* the PascalCase thread DTOs, and the two settings are the `ChatPrivacy` enum served
|
||||
* NUMERICALLY (0 Friends · 1 Favorites · 2 NoOne): this client build carries no by-name enum
|
||||
* formatter, so a string would decode as nothing.
|
||||
*
|
||||
* Reported, not enforced. Nothing on this server stores a per-player privacy setting or
|
||||
* checks one — `GET /thread/checkCanSendDirectMessageWithPrivacySetting` allows every DM —
|
||||
* so these are the values the client renders its privacy screen from.
|
||||
*/
|
||||
export const ChatPrivacySettings = z.object({
|
||||
playerId: z.int().describe('The caller — read from the token, not from the query'),
|
||||
directMessagePrivacySetting: z
|
||||
.int()
|
||||
.describe('Who may DM the caller: 0 Friends · 1 Favorites · 2 NoOne'),
|
||||
groupChatPrivacySetting: z
|
||||
.int()
|
||||
.describe('Who may add the caller to a group chat: 0 Friends · 1 Favorites · 2 NoOne'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /thread/party` — STUB. The real shape hasn't been observed off a live client, so
|
||||
* the route answers an empty object and this schema says so rather than guessing at
|
||||
* fields. Fill both in together once the real response is captured.
|
||||
*/
|
||||
export const PartyThread = z.object({}).describe('Stub — always empty; the real shape is unknown')
|
||||
|
||||
/** `GET /` — the liveness probe. */
|
||||
export const ServiceStatus = z.object({
|
||||
service: z.literal('chat'),
|
||||
|
||||
@@ -376,6 +376,95 @@ describe('thread storage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The party thread is a stub until its real shape is observed off a live client; these
|
||||
// pin down only what the stub promises — auth, and an object body rather than a 404.
|
||||
describe('GET /settings/partyinvite', () => {
|
||||
it('answers the invite-link lifetime as a bare single-key object', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/settings/partyinvite`, {
|
||||
headers: await bearer(885001),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// One key, no `{ success, error, value }` envelope around it.
|
||||
expect(await res.json()).toEqual({ InviteLinkLifetimeInMinutes: 60 })
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/settings/partyinvite`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /thread/party', () => {
|
||||
it('answers an empty object', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
|
||||
headers: await bearer(883001),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({})
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /thread/chatPrivacySetting', () => {
|
||||
it('reports Friends for both settings, keyed to the caller', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
|
||||
headers: await bearer(886001),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// camelCase, and the enum by NUMBER (0 = Friends) — this build has no by-name enum
|
||||
// formatter, so a string would decode as nothing.
|
||||
expect(await res.json()).toEqual({
|
||||
playerId: 886001,
|
||||
directMessagePrivacySetting: 0,
|
||||
groupChatPrivacySetting: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('reads playerId off the token, not a query param', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting?playerId=999999`, {
|
||||
headers: await bearer(886002),
|
||||
})
|
||||
expect(((await res.json()) as { playerId: number }).playerId).toBe(886002)
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /thread/checkCanSendDirectMessageWithPrivacySetting', () => {
|
||||
const path = `${ORIGIN}/thread/checkCanSendDirectMessageWithPrivacySetting`
|
||||
|
||||
it('always allows the DM, as a bare ChatResult integer', async () => {
|
||||
const res = await SELF.fetch(`${path}?receivingPlayerId=205`, {
|
||||
headers: await bearer(884001),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('application/json')
|
||||
// The whole body is the ChatResult — 0 is Success. NOT a boolean: the client
|
||||
// instantiates its response wrapper with the enum, so `true` would decode as nothing.
|
||||
expect(await res.text()).toBe('0')
|
||||
})
|
||||
|
||||
it('answers the same for any player, and with no player named', async () => {
|
||||
// `receivingPlayerId` is ignored — nothing here stores a privacy setting to refuse on.
|
||||
for (const query of ['?receivingPlayerId=205', '?receivingPlayerId=999999', '']) {
|
||||
const res = await SELF.fetch(`${path}${query}`, { headers: await bearer(884002) })
|
||||
expect(await res.text()).toBe('0')
|
||||
}
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${path}?receivingPlayerId=205`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /thread/withmembers', () => {
|
||||
async function withMembers(caller: number, body: string) {
|
||||
return SELF.fetch(`${ORIGIN}/thread/withmembers`, {
|
||||
@@ -760,9 +849,9 @@ describe('ChatMessageReceived push', () => {
|
||||
|
||||
const sent = await hub.getByName('global').takeSent()
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
|
||||
expect(
|
||||
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
|
||||
).toBe(true)
|
||||
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
|
||||
true
|
||||
)
|
||||
expect(sent[0]!.data).toEqual({
|
||||
chatMessageId: chatThread.latestMessage.chatMessageId,
|
||||
chatThreadId: chatThread.chatThreadId,
|
||||
@@ -984,9 +1073,7 @@ describe('POST /thread/:id', () => {
|
||||
it('pushes ChatMessageReceived to every member', async () => {
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
||||
getByName(name: string): {
|
||||
takeSent(): Promise<
|
||||
Array<{ playerId: number; notificationType: NotificationType }>
|
||||
>
|
||||
takeSent(): Promise<Array<{ playerId: number; notificationType: NotificationType }>>
|
||||
}
|
||||
}
|
||||
const caller = 889005
|
||||
@@ -996,9 +1083,9 @@ describe('POST /thread/:id', () => {
|
||||
await send(caller, `/thread/${chatThreadId}`)
|
||||
const sent = await hub.getByName('global').takeSent()
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
|
||||
expect(
|
||||
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
|
||||
).toBe(true)
|
||||
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('reports invalid arguments for blank contents without storing anything', async () => {
|
||||
@@ -1301,7 +1388,11 @@ describe('openapi', () => {
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /thread/{id}/leave',
|
||||
'GET /',
|
||||
'GET /settings/partyinvite',
|
||||
'GET /thread',
|
||||
'GET /thread/chatPrivacySetting',
|
||||
'GET /thread/checkCanSendDirectMessageWithPrivacySetting',
|
||||
'GET /thread/party',
|
||||
'GET /thread/{id}',
|
||||
'GET /thread/{id}/message',
|
||||
'POST /thread',
|
||||
|
||||
Reference in New Issue
Block a user