@open-everywhere/provider-discord 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/approvals/discord-admin.ts +121 -0
- package/agent/approvals/discord-flow.ts +18 -6
- package/agent/channels/discord-dispatch-target.ts +36 -1
- package/agent/channels/discord-events.ts +3 -2
- package/agent/channels/discord-gateway.ts +85 -30
- package/agent/channels/discord-invite.ts +46 -0
- package/agent/channels/discord-origin-codec.ts +11 -0
- package/agent/commands/discord-commands.ts +53 -1
- package/agent/docs-generated.ts +506 -2
- package/agent/register.ts +8 -1
- package/agent/surface-parity.ts +3 -0
- package/dist/agent/approvals/discord-admin.d.ts +31 -0
- package/dist/agent/approvals/discord-admin.d.ts.map +1 -1
- package/dist/agent/approvals/discord-admin.js +107 -0
- package/dist/agent/approvals/discord-admin.js.map +1 -1
- package/dist/agent/approvals/discord-flow.d.ts.map +1 -1
- package/dist/agent/approvals/discord-flow.js +18 -7
- package/dist/agent/approvals/discord-flow.js.map +1 -1
- package/dist/agent/channels/discord-dispatch-target.d.ts +17 -1
- package/dist/agent/channels/discord-dispatch-target.d.ts.map +1 -1
- package/dist/agent/channels/discord-dispatch-target.js +35 -1
- package/dist/agent/channels/discord-dispatch-target.js.map +1 -1
- package/dist/agent/channels/discord-events.d.ts +3 -2
- package/dist/agent/channels/discord-events.d.ts.map +1 -1
- package/dist/agent/channels/discord-events.js.map +1 -1
- package/dist/agent/channels/discord-gateway.d.ts +6 -0
- package/dist/agent/channels/discord-gateway.d.ts.map +1 -1
- package/dist/agent/channels/discord-gateway.js +58 -6
- package/dist/agent/channels/discord-gateway.js.map +1 -1
- package/dist/agent/channels/discord-invite.d.ts +10 -0
- package/dist/agent/channels/discord-invite.d.ts.map +1 -0
- package/dist/agent/channels/discord-invite.js +42 -0
- package/dist/agent/channels/discord-invite.js.map +1 -0
- package/dist/agent/channels/discord-origin-codec.d.ts.map +1 -1
- package/dist/agent/channels/discord-origin-codec.js +12 -0
- package/dist/agent/channels/discord-origin-codec.js.map +1 -1
- package/dist/agent/commands/discord-commands.d.ts.map +1 -1
- package/dist/agent/commands/discord-commands.js +52 -2
- package/dist/agent/commands/discord-commands.js.map +1 -1
- package/dist/agent/docs-generated.d.ts.map +1 -1
- package/dist/agent/docs-generated.js +506 -2
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/register.d.ts.map +1 -1
- package/dist/agent/register.js +8 -2
- package/dist/agent/register.js.map +1 -1
- package/dist/agent/surface-parity.d.ts.map +1 -1
- package/dist/agent/surface-parity.js +3 -0
- package/dist/agent/surface-parity.js.map +1 -1
- package/package.json +6 -2
|
@@ -225,3 +225,124 @@ export async function discordAuthorRoles(guildId: string, userId: string, deps:
|
|
|
225
225
|
export function _resetDiscordAuthorRolesCache(): void {
|
|
226
226
|
roleEvidenceCache.clear();
|
|
227
227
|
}
|
|
228
|
+
|
|
229
|
+
// --- CAN THIS PERSON READ THIS CHANNEL? (OPE-2534, out-of-office cover) --------------------------------------------
|
|
230
|
+
//
|
|
231
|
+
// The cover recap shows a question only where the covered person could already read it. A relay message carries no
|
|
232
|
+
// permission data, so the answer is Discord's own permission rule over three REST reads: the channel (its overwrites,
|
|
233
|
+
// and its parent for a thread), the guild (its owner and role table) and the member (their roles). FAIL-CLOSED like
|
|
234
|
+
// every check in this file: any failure is "cannot read", so the recap counts the question and shows nothing of it.
|
|
235
|
+
|
|
236
|
+
/** Discord's `VIEW_CHANNEL` and `ADMINISTRATOR` permission bits. */
|
|
237
|
+
const VIEW_CHANNEL = 1n << 10n;
|
|
238
|
+
const ADMINISTRATOR = 1n << 3n;
|
|
239
|
+
/** A public thread (10 announcement, 11 public) reads as its parent channel. A private thread (12) does not. */
|
|
240
|
+
const PUBLIC_THREAD_TYPES: ReadonlySet<number> = new Set([10, 11]);
|
|
241
|
+
const PRIVATE_THREAD_TYPE = 12;
|
|
242
|
+
|
|
243
|
+
/** One permission overwrite on a channel. `type` 0 is a role (the @everyone role's id is the guild id), 1 a member. */
|
|
244
|
+
export interface DiscordOverwrite {
|
|
245
|
+
readonly id: string;
|
|
246
|
+
readonly type: number;
|
|
247
|
+
readonly allow: string;
|
|
248
|
+
readonly deny: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The facts Discord's permission rule reads. */
|
|
252
|
+
export interface DiscordViewFacts {
|
|
253
|
+
readonly guildId: string;
|
|
254
|
+
readonly ownerId?: string;
|
|
255
|
+
/** Role id → permission bits, as the guild's role table spells them (a decimal string). */
|
|
256
|
+
readonly rolePermissions: ReadonlyMap<string, string>;
|
|
257
|
+
readonly userId: string;
|
|
258
|
+
readonly memberRoleIds: readonly string[];
|
|
259
|
+
readonly overwrites: readonly DiscordOverwrite[];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function permissionBits(v: string | undefined): bigint {
|
|
263
|
+
return v !== undefined && /^\d+$/.test(v) ? BigInt(v) : 0n;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Discord's documented permission rule for VIEW_CHANNEL (developer docs, "Permissions" → "Permission Overwrites"),
|
|
268
|
+
* in its order. The owner and ADMINISTRATOR see everything. Else the base is the @everyone role's bits OR the
|
|
269
|
+
* member's roles' bits. Then the @everyone overwrite, then the member's role overwrites together, then the member's
|
|
270
|
+
* own overwrite, each as "deny, then allow". Pure.
|
|
271
|
+
*/
|
|
272
|
+
export function discordCanView(f: DiscordViewFacts): boolean {
|
|
273
|
+
if (f.ownerId !== undefined && f.ownerId === f.userId) return true;
|
|
274
|
+
let base = permissionBits(f.rolePermissions.get(f.guildId));
|
|
275
|
+
for (const r of f.memberRoleIds) base |= permissionBits(f.rolePermissions.get(r));
|
|
276
|
+
if ((base & ADMINISTRATOR) !== 0n) return true;
|
|
277
|
+
const everyone = f.overwrites.find((o) => o.type === 0 && o.id === f.guildId);
|
|
278
|
+
if (everyone) base = (base & ~permissionBits(everyone.deny)) | permissionBits(everyone.allow);
|
|
279
|
+
let allow = 0n;
|
|
280
|
+
let deny = 0n;
|
|
281
|
+
for (const o of f.overwrites) {
|
|
282
|
+
if (o.type === 0 && o.id !== f.guildId && f.memberRoleIds.includes(o.id)) {
|
|
283
|
+
allow |= permissionBits(o.allow);
|
|
284
|
+
deny |= permissionBits(o.deny);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
base = (base & ~deny) | allow;
|
|
288
|
+
const own = f.overwrites.find((o) => o.type === 1 && o.id === f.userId);
|
|
289
|
+
if (own) base = (base & ~permissionBits(own.deny)) | permissionBits(own.allow);
|
|
290
|
+
return (base & VIEW_CHANNEL) !== 0n;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function overwritesOf(channel: Record<string, unknown>): DiscordOverwrite[] {
|
|
294
|
+
const raw = Array.isArray(channel["permission_overwrites"]) ? (channel["permission_overwrites"] as unknown[]) : [];
|
|
295
|
+
return raw.flatMap((o) =>
|
|
296
|
+
isObject(o) && typeof o["id"] === "string" && typeof o["type"] === "number"
|
|
297
|
+
? [{ id: o["id"], type: o["type"], allow: String(o["allow"] ?? "0"), deny: String(o["deny"] ?? "0") }]
|
|
298
|
+
: [],
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Can `userId` read `channelId` in `guildId`, per Discord's own rule ({@link discordCanView})? A public thread reads
|
|
304
|
+
* as its parent channel. Each of these answers FALSE: a private thread, a channel in another guild, a person who is
|
|
305
|
+
* not a member (404), a bot account (cover is for people), and every failure. NEVER throws. Not cached: cover asks
|
|
306
|
+
* only about a person with live cover.
|
|
307
|
+
*/
|
|
308
|
+
export async function discordMemberCanViewChannel(guildId: string, channelId: string, userId: string, deps: DiscordAdminDeps = {}): Promise<boolean> {
|
|
309
|
+
const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
|
|
310
|
+
if (!token || !guildId || !channelId || !userId) return false;
|
|
311
|
+
const doFetch = deps.fetchFn ?? fetch;
|
|
312
|
+
const headers = { authorization: `Bot ${token}` };
|
|
313
|
+
const read = async (path: string): Promise<Record<string, unknown> | undefined> => {
|
|
314
|
+
const res = await doFetch(`https://discord.com/api/v10/${path}`, { headers });
|
|
315
|
+
if (!res.ok) return undefined;
|
|
316
|
+
const body = (await res.json()) as unknown;
|
|
317
|
+
return isObject(body) ? body : undefined;
|
|
318
|
+
};
|
|
319
|
+
try {
|
|
320
|
+
let channel = await read(`channels/${encodeURIComponent(channelId)}`);
|
|
321
|
+
if (!channel || channel["guild_id"] !== guildId || channel["type"] === PRIVATE_THREAD_TYPE) return false;
|
|
322
|
+
if (typeof channel["type"] === "number" && PUBLIC_THREAD_TYPES.has(channel["type"])) {
|
|
323
|
+
const parentId = channel["parent_id"];
|
|
324
|
+
channel = typeof parentId === "string" ? await read(`channels/${encodeURIComponent(parentId)}`) : undefined;
|
|
325
|
+
if (!channel || channel["guild_id"] !== guildId) return false;
|
|
326
|
+
}
|
|
327
|
+
const guild = await read(`guilds/${encodeURIComponent(guildId)}`);
|
|
328
|
+
if (!guild) return false;
|
|
329
|
+
const member = await read(`guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(userId)}`);
|
|
330
|
+
const user = member?.["user"];
|
|
331
|
+
if (!member || !isObject(user) || user["id"] !== userId || user["bot"] === true) return false;
|
|
332
|
+
const rolePermissions = new Map<string, string>();
|
|
333
|
+
for (const r of Array.isArray(guild["roles"]) ? (guild["roles"] as unknown[]) : []) {
|
|
334
|
+
if (isObject(r) && typeof r["id"] === "string") rolePermissions.set(r["id"], String(r["permissions"] ?? "0"));
|
|
335
|
+
}
|
|
336
|
+
return discordCanView({
|
|
337
|
+
guildId,
|
|
338
|
+
...(typeof guild["owner_id"] === "string" ? { ownerId: guild["owner_id"] } : {}),
|
|
339
|
+
rolePermissions,
|
|
340
|
+
userId,
|
|
341
|
+
memberRoleIds: Array.isArray(member["roles"]) ? (member["roles"] as unknown[]).filter((r): r is string => typeof r === "string") : [],
|
|
342
|
+
overwrites: overwritesOf(channel),
|
|
343
|
+
});
|
|
344
|
+
} catch (err) {
|
|
345
|
+
log(`view check error for guild ${guildId}: ${(err as Error)?.message ?? String(err)}`);
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
type DecisionWakeDeps,
|
|
67
67
|
type ApprovalOutcome,
|
|
68
68
|
type PendingActionRow,
|
|
69
|
+
CLICK_NOTICE,
|
|
69
70
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
70
71
|
import {
|
|
71
72
|
OE_DISCORD_APPROVAL_PREFIX,
|
|
@@ -277,7 +278,7 @@ export interface DiscordApprovalDeps {
|
|
|
277
278
|
export function discordResponseFor(res: ApprovalClickResolution): DiscordInteractionResponse {
|
|
278
279
|
switch (res.outcome) {
|
|
279
280
|
case "unknown":
|
|
280
|
-
return ephemeral(
|
|
281
|
+
return ephemeral(CLICK_NOTICE.unknown);
|
|
281
282
|
case "not-pending":
|
|
282
283
|
case "expired-race":
|
|
283
284
|
case "unregistered-kind":
|
|
@@ -288,7 +289,7 @@ export function discordResponseFor(res: ApprovalClickResolution): DiscordInterac
|
|
|
288
289
|
return { type: RESPONSE_UPDATE_MESSAGE, data: { content: card.content, components: card.components } };
|
|
289
290
|
}
|
|
290
291
|
case "denied":
|
|
291
|
-
return ephemeral(
|
|
292
|
+
return ephemeral(CLICK_NOTICE.deniedDiscord);
|
|
292
293
|
case "clicker-denied":
|
|
293
294
|
// The clicker gate (OPE-185, core approvals/clicker-floor.ts) — same UX as `denied`: an
|
|
294
295
|
// ephemeral to the clicker alone, card untouched, row still pending.
|
|
@@ -357,7 +358,7 @@ export async function handleDiscordApprovalInteraction(raw: unknown, deps: Disco
|
|
|
357
358
|
if (!click.clickerId) {
|
|
358
359
|
// It WAS ours; fail soft on the click rather than forward a half-understood payload.
|
|
359
360
|
console.error(`[open-everywhere:discord-approvals] component click "${click.customId}" carries no user id — ignoring the click`);
|
|
360
|
-
return ephemeral(
|
|
361
|
+
return ephemeral(CLICK_NOTICE.noClicker);
|
|
361
362
|
}
|
|
362
363
|
const clickerId = click.clickerId;
|
|
363
364
|
|
|
@@ -395,8 +396,11 @@ export async function handleDiscordApprovalInteraction(raw: unknown, deps: Disco
|
|
|
395
396
|
return rowGuild ? isGuildAdminViaRest(rowGuild, clickerId) : false;
|
|
396
397
|
},
|
|
397
398
|
});
|
|
398
|
-
// Decision FEEDBACK (task #106 — Slack click-path parity):
|
|
399
|
-
//
|
|
399
|
+
// Decision FEEDBACK (task #106 — Slack click-path parity): the cross-thread window wake, and a
|
|
400
|
+
// fresh notice beside the card that states the outcome only when no seat took the wake or a thread
|
|
401
|
+
// seat or a worker owns the card's channel (the UPDATE_MESSAGE response is an EDIT, invisible to
|
|
402
|
+
// backscroll; the room never reads the decided line twice, OPE-2473). Otherwise only an asker whom
|
|
403
|
+
// somebody else answered, or whose request expired, gets one short line (OPE-2490).
|
|
400
404
|
// The wake's guild id comes from the SIGNED interaction (Slack's team.id analogue — the frozen
|
|
401
405
|
// scopeKey can name a different workspace under an explicit-scope override). A DM-context
|
|
402
406
|
// interaction has none, so the core takes the card's mint-frozen DM `postedTo` as the DM seat's
|
|
@@ -419,6 +423,10 @@ export async function handleDiscordApprovalInteraction(raw: unknown, deps: Disco
|
|
|
419
423
|
outcomeLine: "expired",
|
|
420
424
|
executed: "none",
|
|
421
425
|
target: discordDecisionWakeTarget(res.row, click.guildId),
|
|
426
|
+
// The interaction answer (UPDATE_MESSAGE) edits the card to its decided line.
|
|
427
|
+
cardShowsOutcome: true,
|
|
428
|
+
// The notice posts into the card's own channel; a Discord thread IS its channel id.
|
|
429
|
+
noticeThread: discordDecisionWakeTarget(res.row, click.guildId)?.origin ?? null,
|
|
422
430
|
postNotice: (text) => (deps.postNotice ?? postDiscordDecisionNotice)(res.row, text),
|
|
423
431
|
},
|
|
424
432
|
deps.decisionWake,
|
|
@@ -434,6 +442,10 @@ export async function handleDiscordApprovalInteraction(raw: unknown, deps: Disco
|
|
|
434
442
|
executed: res.executed,
|
|
435
443
|
clickerId,
|
|
436
444
|
target: discordDecisionWakeTarget(res.row, click.guildId),
|
|
445
|
+
// The interaction answer (UPDATE_MESSAGE) edits the card to its decided line.
|
|
446
|
+
cardShowsOutcome: true,
|
|
447
|
+
// The notice posts into the card's own channel; a Discord thread IS its channel id.
|
|
448
|
+
noticeThread: discordDecisionWakeTarget(res.row, click.guildId)?.origin ?? null,
|
|
437
449
|
postNotice: (text) => (deps.postNotice ?? postDiscordDecisionNotice)(res.row, text),
|
|
438
450
|
},
|
|
439
451
|
deps.decisionWake,
|
|
@@ -465,6 +477,6 @@ export async function handleDiscordApprovalInteraction(raw: unknown, deps: Disco
|
|
|
465
477
|
// Genuinely unexpected (store outage, …) after we'd established this WAS our click —
|
|
466
478
|
// fail soft with an honest ephemeral note; never throw out of the interactions path.
|
|
467
479
|
console.error(`[open-everywhere:discord-approvals] click handling failed softly for "${click.customId}": ${String(err)}`);
|
|
468
|
-
return ephemeral(
|
|
480
|
+
return ephemeral(CLICK_NOTICE.failed);
|
|
469
481
|
}
|
|
470
482
|
}
|
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
import type {
|
|
13
13
|
Routine,
|
|
14
14
|
DispatchTarget,
|
|
15
|
+
RoutineTestRunArm,
|
|
15
16
|
DeclaredTargetResolution,
|
|
16
17
|
DeclaredTargetSpec,
|
|
17
18
|
SessionMode,
|
|
18
19
|
UnifiedWakeOrigin,
|
|
19
20
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
20
21
|
import discord from "./discord.js";
|
|
21
|
-
import { discordOpenDmChannel } from "./discord-post.js";
|
|
22
|
+
import { discordOpenDmChannel, discordPostMessage } from "./discord-post.js";
|
|
22
23
|
import { fetchDiscordInfo } from "./discord-channel-info.js";
|
|
23
24
|
|
|
24
25
|
/** One stored-target field as a non-empty string, or undefined (never a coerced non-string — the
|
|
@@ -162,3 +163,37 @@ export async function resolveDiscordDeclaredTarget(
|
|
|
162
163
|
}
|
|
163
164
|
return { ok: true, facts: { surfaceKey: `discord/${spec.principalId}/${channelId}`, target: { channelId, channelType: "1" } } };
|
|
164
165
|
}
|
|
166
|
+
|
|
167
|
+
/** The channel a resolved Discord target posts into, or undefined. */
|
|
168
|
+
function targetChannelId(dest: DispatchTarget): string | undefined {
|
|
169
|
+
const v = (dest.target as { channelId?: unknown }).channelId;
|
|
170
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* THE DISCORD TEST-RUN ARM (OPE-2540, core routines/test-run.ts). A test needs a session of its own,
|
|
175
|
+
* and Discord has no thread ts to key one on. eve's Discord channel keys a session on
|
|
176
|
+
* `(channelId, conversationId)` (`discordContinuationToken`), and a proactive fire with no
|
|
177
|
+
* `conversationId` keys on the channel alone, which is the room's own session. So the arm posts the
|
|
178
|
+
* test's opening message and fires with that message's id as the `conversationId`: a session of its
|
|
179
|
+
* own, in the routine's own channel.
|
|
180
|
+
*
|
|
181
|
+
* The opening post, the run's answer and the summary all land in the routine's channel. The summary is
|
|
182
|
+
* a Discord REPLY to the opening post (`message_reference`), so it shows which test it closes.
|
|
183
|
+
* The fire's bag is unchanged, so the test binds to the routine's own channel and guild.
|
|
184
|
+
*
|
|
185
|
+
* Not a Discord thread: a thread is its own channel id, and bindDiscord would then bind the test to a
|
|
186
|
+
* different surface than the routine's.
|
|
187
|
+
*/
|
|
188
|
+
export const discordTestRunArm: RoutineTestRunArm = {
|
|
189
|
+
post: async (dest, text, anchor) => {
|
|
190
|
+
const channelId = targetChannelId(dest);
|
|
191
|
+
if (channelId === undefined) return undefined;
|
|
192
|
+
try {
|
|
193
|
+
return await discordPostMessage(channelId, text, anchor !== undefined ? { replyToMessageId: anchor } : undefined);
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
target: (dest, anchor) => ({ channelId: targetChannelId(dest) ?? "", conversationId: anchor }),
|
|
199
|
+
};
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { POST, type Channel, type RouteHandlerArgs } from "eve/channels";
|
|
31
31
|
import { verifyDiscordInteraction } from "./discord-front.js";
|
|
32
32
|
import { appendAudit, deploymentOrg } from "@open-everywhere/core/agent/adapter-api.js";
|
|
33
|
+
import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
|
|
33
34
|
|
|
34
35
|
/** The public URL to paste into the app's developer-portal **Webhooks** page (NOT the
|
|
35
36
|
* Interactions Endpoint URL, which stays {@link PUBLIC_DISCORD_ROUTE}). */
|
|
@@ -48,7 +49,7 @@ function elog(msg: string): void {
|
|
|
48
49
|
* all of which still get Discord's required 204. */
|
|
49
50
|
export type DiscordEventDecision =
|
|
50
51
|
| { readonly kind: "ack"; readonly reason: string }
|
|
51
|
-
| { readonly kind: "audit"; readonly action: string; readonly workspace: string; readonly principalId?: string; readonly detail:
|
|
52
|
+
| { readonly kind: "audit"; readonly action: string; readonly workspace: string; readonly principalId?: string; readonly detail: AuditDetail };
|
|
52
53
|
|
|
53
54
|
function str(v: unknown): string | undefined {
|
|
54
55
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
@@ -118,7 +119,7 @@ export interface DiscordEventsOptions {
|
|
|
118
119
|
readonly publicKey?: string;
|
|
119
120
|
readonly now?: () => number;
|
|
120
121
|
/** Audit writer (defaults to the real appendAudit under the deployment org). */
|
|
121
|
-
readonly audit?: (row: { action: string; workspace: string; principalId?: string; detail:
|
|
122
|
+
readonly audit?: (row: { action: string; workspace: string; principalId?: string; detail: AuditDetail }) => Promise<void>;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
/** Discord requires an EMPTY 204 body for both the PING and every event, within 3 seconds. */
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
// expires by itself, so there is nothing to track or clean up across
|
|
49
49
|
// turns); set "false"/"0" off.
|
|
50
50
|
|
|
51
|
+
import { auditErrorDetail } from "@open-everywhere/core/agent/adapter-api.js";
|
|
51
52
|
import { defineChannel, POST, type RouteHandlerArgs } from "eve/channels";
|
|
52
53
|
import { buildDiscordAuth, type OpenEverywhereDiscordAuth } from "./discord-identity.js";
|
|
53
54
|
import { threadParentChannelId } from "./discord-channel-info.js";
|
|
@@ -83,6 +84,7 @@ import {
|
|
|
83
84
|
type AttachSessionForCancel,
|
|
84
85
|
domainKeyOf,
|
|
85
86
|
resolvePartition,
|
|
87
|
+
auditSurfaceModeFrom,
|
|
86
88
|
nameDropWakeEnabled,
|
|
87
89
|
seatCatchUp,
|
|
88
90
|
seatCatchUpSinceMs,
|
|
@@ -134,6 +136,8 @@ import {
|
|
|
134
136
|
healPrunedDeploymentStrand,
|
|
135
137
|
engagedFollowUpContract,
|
|
136
138
|
dispatchNativeWakeWithRetry,
|
|
139
|
+
parseVoiceSync,
|
|
140
|
+
syncDiscordVoice,
|
|
137
141
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
138
142
|
|
|
139
143
|
// Re-exported so every existing importer (scripts/discord-gateway-test.ts, the completion
|
|
@@ -174,7 +178,9 @@ import {
|
|
|
174
178
|
auditNoReplySentinelObserved,
|
|
175
179
|
getSettings,
|
|
176
180
|
authPrincipalId,
|
|
181
|
+
coverForWake,
|
|
177
182
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
183
|
+
import { discordMemberCanViewChannel } from "../approvals/discord-admin.js";
|
|
178
184
|
import { fetchDiscordBackscrollLines, gradeDiscordContextLines, DISCORD_RAW_FETCH_REACHBACK_HOURS } from "./discord-backscroll.js";
|
|
179
185
|
|
|
180
186
|
function log(msg: string): void {
|
|
@@ -272,6 +278,20 @@ function strOrNum(v: unknown): string | undefined {
|
|
|
272
278
|
return undefined;
|
|
273
279
|
}
|
|
274
280
|
|
|
281
|
+
/**
|
|
282
|
+
* The people a Discord message mentions (OPE-2534, out-of-office cover), by user id, in the order the message names
|
|
283
|
+
* them: `<@id>` and the legacy nickname form `<@!id>`. A role mention (`<@&id>`), a channel mention (`<#id>`) and the
|
|
284
|
+
* bot's own mention are no person, so they are left out. Pure.
|
|
285
|
+
*/
|
|
286
|
+
export function discordMentionedUserIds(content: string, botId?: string): string[] {
|
|
287
|
+
const ids: string[] = [];
|
|
288
|
+
for (const m of content.matchAll(/<@!?(\d{17,20})>/g)) {
|
|
289
|
+
const id = m[1]!;
|
|
290
|
+
if (id !== botId) ids.push(id);
|
|
291
|
+
}
|
|
292
|
+
return ids;
|
|
293
|
+
}
|
|
294
|
+
|
|
275
295
|
/**
|
|
276
296
|
* Defensively parse the relay envelope. Returns null for anything not recognizably a relay
|
|
277
297
|
* MESSAGE_CREATE with the identity facts we MUST have to dispatch + reply — a channel id (for
|
|
@@ -1952,7 +1972,7 @@ async function divertDiscordDmToWindow(input: {
|
|
|
1952
1972
|
// dominant convention for a wake-shaped audit row (line ~2112/2259/2330 below), not the raw
|
|
1953
1973
|
// `partition.surface` channel id.
|
|
1954
1974
|
await auditSupervisorFailure({ org, platform: input.partition.platform, workspace: input.partition.workspace, surface: surfaceKey }, "surface.wake_failed", {
|
|
1955
|
-
message:
|
|
1975
|
+
message: auditErrorDetail(err).error,
|
|
1956
1976
|
wakeId,
|
|
1957
1977
|
addressing: "direct-mention",
|
|
1958
1978
|
});
|
|
@@ -2144,34 +2164,55 @@ export async function divertDiscordWakeToUnifiedWindow(input: {
|
|
|
2144
2164
|
// channel's wake stays top-level and the standing seat keeps it.
|
|
2145
2165
|
...(isThreadChannelType(msg.channelType) ? { thread: true as const } : {}),
|
|
2146
2166
|
};
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2167
|
+
// 🔴 OUT-OF-OFFICE COVER (OPE-2534, the Slack seam of OPE-2513). A message that mentions a person with live cover
|
|
2168
|
+
// gets OE's cover note on THIS wake, and nothing else changes: the binding, the auth bag and the room's memory stay
|
|
2169
|
+
// the room's, so the answer can use only what this room already knows. The reply's label and the recap key on this
|
|
2170
|
+
// wake id (cover/wake.ts). A cover read that fails covers nobody.
|
|
2171
|
+
// No Discord room is public to the recap, because a relay message carries no permission data. Discord's own rule
|
|
2172
|
+
// (approvals/discord-admin.ts `discordMemberCanViewChannel`) confirms, per covered person, that they can read it.
|
|
2173
|
+
const cover = await coverForWake({
|
|
2174
|
+
org,
|
|
2175
|
+
platform: "discord",
|
|
2176
|
+
workspace: input.partition.workspace,
|
|
2177
|
+
surface: input.partition.surface,
|
|
2178
|
+
surfaceKey: input.partition.surfaceKey,
|
|
2179
|
+
channelId: msg.channelId,
|
|
2180
|
+
roomPublic: false,
|
|
2181
|
+
wakeId,
|
|
2182
|
+
mentionedIds: discordMentionedUserIds(msg.content, msg.botId),
|
|
2183
|
+
askerId: msg.author.id,
|
|
2184
|
+
...(msg.author.username ? { askerName: msg.author.username } : {}),
|
|
2185
|
+
text: msg.content,
|
|
2186
|
+
now: new Date(atIso),
|
|
2187
|
+
...(msg.guildId ? { confirmInRoom: (id: string) => discordMemberCanViewChannel(msg.guildId!, msg.channelId, id) } : {}),
|
|
2188
|
+
}).catch(() => ({ covered: [] }) as { systemNote?: string });
|
|
2189
|
+
const built = buildDiscordUnifiedWake({
|
|
2190
|
+
guildId: msg.guildId ?? "",
|
|
2191
|
+
channelId: msg.channelId,
|
|
2192
|
+
parentChannelId: str(input.auth.attributes.parent_channel_id),
|
|
2193
|
+
isThread: isThreadChannelType(msg.channelType),
|
|
2194
|
+
addressing: input.addressing,
|
|
2195
|
+
threadEngaged: input.threadEngaged,
|
|
2196
|
+
wakeId,
|
|
2197
|
+
engagementTier: discordEngagementTier(domainKeyOf(input.partition)),
|
|
2198
|
+
tail,
|
|
2199
|
+
// 🔴 THE HYDRATION RIDES THE WAKE, NOT A PRE-RENDERED STRING (OPE-1749). `renderWake` holds
|
|
2200
|
+
// both slots — these lines and `tail` — so core de-duplicates them against each other and
|
|
2201
|
+
// spends the cap below that de-duplication, the same road every other surface takes.
|
|
2202
|
+
contextLines: input.contextLines,
|
|
2203
|
+
contextSource: input.contextSource,
|
|
2204
|
+
botName: msg.botName,
|
|
2205
|
+
catchUpUnavailable,
|
|
2206
|
+
trigger: {
|
|
2207
|
+
id: msg.id || "unknown",
|
|
2208
|
+
author: msg.author.username ?? msg.author.id,
|
|
2209
|
+
authorId: msg.author.id,
|
|
2210
|
+
sentAt: atIso,
|
|
2211
|
+
text: msg.content,
|
|
2212
|
+
trust,
|
|
2213
|
+
},
|
|
2214
|
+
});
|
|
2215
|
+
const rendered = renderWake(cover.systemNote ? { ...built, systemNote: built.systemNote ? `${built.systemNote}\n${cover.systemNote}` : cover.systemNote } : built);
|
|
2175
2216
|
const domainSurfaceKey = domainKeyOf(input.partition);
|
|
2176
2217
|
try {
|
|
2177
2218
|
// THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719) — the INBOUND variant, the one Slack's front
|
|
@@ -2225,7 +2266,7 @@ export async function divertDiscordWakeToUnifiedWindow(input: {
|
|
|
2225
2266
|
await auditSupervisorFailure(
|
|
2226
2267
|
{ org: deploymentOrg(), platform: input.partition.platform, workspace: input.partition.workspace, surface: domainSurfaceKey },
|
|
2227
2268
|
"surface.wake_failed",
|
|
2228
|
-
{ message:
|
|
2269
|
+
{ message: auditErrorDetail(err).error, wakeId, addressing: input.addressing },
|
|
2229
2270
|
);
|
|
2230
2271
|
throw err;
|
|
2231
2272
|
}
|
|
@@ -2343,6 +2384,8 @@ export async function dispatchTurn(
|
|
|
2343
2384
|
}
|
|
2344
2385
|
}
|
|
2345
2386
|
const partition: Partition = resolvePartition(ctxFromAuth(auth));
|
|
2387
|
+
// OPE-1940: the RESOLVED mode of the same surface, spread into every audit row below that names it.
|
|
2388
|
+
const auditMode = auditSurfaceModeFrom(ctxFromAuth(auth));
|
|
2346
2389
|
const key = engagementKey(msg);
|
|
2347
2390
|
const isDm = isDmChannel(msg.channelType);
|
|
2348
2391
|
// A DM or a mention always wakes; only a plain guild message needs the (async) engagement lookup.
|
|
@@ -2395,6 +2438,7 @@ export async function dispatchTurn(
|
|
|
2395
2438
|
platform: "discord",
|
|
2396
2439
|
workspace: partition.workspace,
|
|
2397
2440
|
surface: partition.surfaceKey,
|
|
2441
|
+
...auditMode,
|
|
2398
2442
|
principalId: msg.author.id,
|
|
2399
2443
|
action: "discord.external_policy_suppressed",
|
|
2400
2444
|
detail: { verdict: externalVerdict, channelId: msg.channelId, ...suppressionRendering(acked) },
|
|
@@ -2558,6 +2602,7 @@ export async function dispatchTurn(
|
|
|
2558
2602
|
// which is what `partition` already carries — no fabricated guild id.
|
|
2559
2603
|
workspace: partition.workspace,
|
|
2560
2604
|
surface: partition.surfaceKey,
|
|
2605
|
+
...auditMode,
|
|
2561
2606
|
principalId: msg.author.id,
|
|
2562
2607
|
action: "discord.dispatched",
|
|
2563
2608
|
detail: { addressing: "direct-mention", dmWindow: true, channelId: msg.channelId },
|
|
@@ -2638,6 +2683,7 @@ export async function dispatchTurn(
|
|
|
2638
2683
|
platform: "discord",
|
|
2639
2684
|
workspace: msg.guildId ?? "unknown",
|
|
2640
2685
|
surface: partition.surfaceKey,
|
|
2686
|
+
...auditMode,
|
|
2641
2687
|
principalId: msg.author.id,
|
|
2642
2688
|
action: "discord.dispatched",
|
|
2643
2689
|
detail: { addressing, singleWindow: true, channelId: msg.channelId },
|
|
@@ -3007,6 +3053,15 @@ export default withEndsTurnFinalChannel(defineChannel<DiscordGatewayState, Disco
|
|
|
3007
3053
|
return new Response("ok", { status: 200 });
|
|
3008
3054
|
}
|
|
3009
3055
|
|
|
3056
|
+
// Discord voice meetings (OPE-2473) — the relay's VOICE_SYNC. Like the heartbeat it mints no session and no
|
|
3057
|
+
// wake. It is answered INLINE, because the answer IS the payload: the voice channels the relay must be in
|
|
3058
|
+
// (core meetings/discord-voice.ts). The relay's report (credentials, members) is stored for the meeting box.
|
|
3059
|
+
const voiceSync = parseVoiceSync(json);
|
|
3060
|
+
if (voiceSync) {
|
|
3061
|
+
const answer = await syncDiscordVoice(deploymentOrg(), voiceSync);
|
|
3062
|
+
return new Response(JSON.stringify(answer), { status: 200, headers: { "content-type": "application/json" } });
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3010
3065
|
// Inbound reaction — a distinct relay payload shape. Handled before the message parse:
|
|
3011
3066
|
// ingest async (telemetry always, a durable feedback row on a bot reply), ack immediately.
|
|
3012
3067
|
// A reaction never starts a turn — but since OPE-418 a ✋ can STOP one, which is why
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The Discord invite lookup for meeting links (OPE-2473): `GET /invites/{code}` with the bot token, registered with
|
|
2
|
+
// core's meeting invite seam (core meetings/invite.ts) from registerDiscordProvider(). A person who uses Discord's
|
|
3
|
+
// "Invite to voice channel" pastes `https://discord.gg/<code>`; this answers which server and channel the code
|
|
4
|
+
// opens and what kind of channel it is. Core then builds the channel's own link and runs every usual check on it.
|
|
5
|
+
//
|
|
6
|
+
// The answer is Discord's data and core treats it as untrusted: core re-parses the ids through the same strict
|
|
7
|
+
// link parse a pasted link meets. The bot token is a SECRET — header only, never logged. No cache: an invite is
|
|
8
|
+
// looked up once per ask, and an expired code must read as expired.
|
|
9
|
+
|
|
10
|
+
import type { MeetingInviteLookup, MeetingInviteResolver } from "@open-everywhere/core/agent/adapter-api.js";
|
|
11
|
+
|
|
12
|
+
/** Discord's channel types for a voice room: GUILD_VOICE (2) and GUILD_STAGE_VOICE (13). */
|
|
13
|
+
const GUILD_VOICE = 2;
|
|
14
|
+
const GUILD_STAGE_VOICE = 13;
|
|
15
|
+
/** A Discord id (a snowflake): 17 to 20 digits. */
|
|
16
|
+
const SNOWFLAKE = /^\d{17,20}$/;
|
|
17
|
+
|
|
18
|
+
export interface DiscordInviteDeps {
|
|
19
|
+
readonly fetchFn?: typeof fetch;
|
|
20
|
+
readonly token?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Look one invite code up. NEVER throws: every failure is a `found: false` answer core turns into a plain reason. */
|
|
24
|
+
export async function lookUpDiscordInvite(code: string, deps: DiscordInviteDeps = {}): Promise<MeetingInviteLookup> {
|
|
25
|
+
const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
|
|
26
|
+
if (!token) return { found: false, why: "unreachable" };
|
|
27
|
+
try {
|
|
28
|
+
const doFetch = deps.fetchFn ?? fetch;
|
|
29
|
+
const res = await doFetch(`https://discord.com/api/v10/invites/${encodeURIComponent(code)}`, { headers: { authorization: `Bot ${token}` } });
|
|
30
|
+
// Discord answers an expired, revoked or mistyped code with 404 (Unknown Invite).
|
|
31
|
+
if (res.status === 404) return { found: false, why: "unknown" };
|
|
32
|
+
if (!res.ok) return { found: false, why: "unreachable" };
|
|
33
|
+
const body = (await res.json()) as { guild?: { id?: unknown } | null; channel?: { id?: unknown; type?: unknown } | null };
|
|
34
|
+
const guildId = body.guild?.id;
|
|
35
|
+
const channelId = body.channel?.id;
|
|
36
|
+
if (typeof guildId !== "string" || !SNOWFLAKE.test(guildId) || typeof channelId !== "string" || !SNOWFLAKE.test(channelId)) return { found: false, why: "no-channel" };
|
|
37
|
+
const type = body.channel?.type;
|
|
38
|
+
const channelKind = type === GUILD_VOICE ? "voice" : type === GUILD_STAGE_VOICE ? "stage" : "other";
|
|
39
|
+
return { found: true, guildId, channelId, channelKind };
|
|
40
|
+
} catch {
|
|
41
|
+
return { found: false, why: "unreachable" };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The resolver core's seam holds: the live lookup, with the live token and fetch. */
|
|
46
|
+
export const discordInviteResolver: MeetingInviteResolver = (code) => lookUpDiscordInvite(code);
|
|
@@ -80,6 +80,17 @@ export const discordWakeOriginCodec: WakeOriginCodec<DiscordOrigin> = {
|
|
|
80
80
|
// which is the fail-closed direction and byte-identical to the behaviour this replaced.
|
|
81
81
|
isThreadScoped: (origin) => !isDmOrigin(origin) && origin.thread === true,
|
|
82
82
|
containerId: (origin) => origin.channelId,
|
|
83
|
+
// THE THREAD A REPLY OPENS (OPE-2558, core routines/report-thread.ts). A Discord thread started from a
|
|
84
|
+
// message takes that message's id as its own channel id, so a thread a person opens off a top-level
|
|
85
|
+
// post is the guild channel `<post id>` with `thread: true` (the shape the gateway mints for a message
|
|
86
|
+
// in it). A post that is already in a thread answers undefined, because its own `threadRef` is the key.
|
|
87
|
+
// A DM has no threads. An origin with `autoThreadName` answers undefined: its arm may move the post
|
|
88
|
+
// into a thread of its own, so the post id does not name the thread a reply opens. A plain Discord
|
|
89
|
+
// reply (a message reference in the channel) is not a thread, so only a thread off the post counts.
|
|
90
|
+
replyThreadUnder: (origin, messageId) => {
|
|
91
|
+
if (isDmOrigin(origin) || origin.thread === true || origin.autoThreadName !== undefined) return undefined;
|
|
92
|
+
return { platform: "discord", guildId: origin.guildId, channelId: messageId, thread: true };
|
|
93
|
+
},
|
|
83
94
|
// THE PLACEMENT AXIS (OPE-1826). Discord is the SECOND surface whose post placement sits OUTSIDE
|
|
84
95
|
// its conversation identity, and until this member existed nothing said so: `threadRef` keys on the
|
|
85
96
|
// channel id alone (a Discord thread IS its own channel id), while the post arm hangs the answer on
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
agentDisplayName,
|
|
34
34
|
oeCommandName,
|
|
35
35
|
oeSlash,
|
|
36
|
+
parseOeCommandTokens,
|
|
36
37
|
runOeCommand,
|
|
37
38
|
type AttachSessionForCancel,
|
|
38
39
|
type BindableContext,
|
|
@@ -48,9 +49,16 @@ const RESPONSE_CHANNEL_MESSAGE = 4; // CHANNEL_MESSAGE_WITH_SOURCE — a fresh r
|
|
|
48
49
|
const EPHEMERAL_FLAG = 64; // only the invoking user sees the reply
|
|
49
50
|
|
|
50
51
|
function ephemeral(content: string): DiscordInteractionResponse {
|
|
51
|
-
return { type: RESPONSE_CHANNEL_MESSAGE, data: { content, flags: EPHEMERAL_FLAG } };
|
|
52
|
+
return { type: RESPONSE_CHANNEL_MESSAGE, data: { content: content.length > 0 ? content : DISCORD_CARD_ACK, flags: EPHEMERAL_FLAG } };
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The private acknowledgement for a command whose card IS the answer (OPE-2490, core's
|
|
57
|
+
* `CARD_IS_THE_ANSWER`). Discord must answer an interaction with content, or it shows "The application
|
|
58
|
+
* did not respond". So the answer is the smallest one there is: it says nothing the card says.
|
|
59
|
+
*/
|
|
60
|
+
const DISCORD_CARD_ACK = "👍";
|
|
61
|
+
|
|
54
62
|
// --- defensive wire-JSON probing (untrusted body, post-signature-check) ---------------------
|
|
55
63
|
// Mirrors approvals/discord-flow.ts's parseDiscordComponentClick posture exactly: this is
|
|
56
64
|
// attacker-shaped JSON reaching a public URL (Discord relays whatever a client sends), so
|
|
@@ -130,6 +138,20 @@ export function parseDiscordOeCommandOptions(rawOptions: unknown): OeCommand {
|
|
|
130
138
|
}
|
|
131
139
|
return { kind: "rate", score };
|
|
132
140
|
}
|
|
141
|
+
if (name === "cover") {
|
|
142
|
+
// OPE-2534: the out-of-office cover switch. Core's own grammar parses it (commands/cover.ts), so `/oe cover on until
|
|
143
|
+
// 2026-10-20` means on Discord what it means on Slack and Teams, and the same date check answers.
|
|
144
|
+
const sub = optArray(first.options)[0];
|
|
145
|
+
const subName = sub ? str(sub.name) : undefined;
|
|
146
|
+
const until = str(findOpt(optArray(sub?.options), "until")?.value);
|
|
147
|
+
return parseOeCommandTokens(["cover", ...(subName ? [subName] : []), ...(subName === "on" && until ? ["until", until] : [])]);
|
|
148
|
+
}
|
|
149
|
+
if (name === "suggest") {
|
|
150
|
+
// OPE-2550: the room's stop for routine suggestions. Core's own grammar parses it (commands/suggest.ts).
|
|
151
|
+
const sub = optArray(first.options)[0];
|
|
152
|
+
const subName = sub ? str(sub.name) : undefined;
|
|
153
|
+
return parseOeCommandTokens(["suggest", ...(subName ? [subName] : [])]);
|
|
154
|
+
}
|
|
133
155
|
if (name === "budget") {
|
|
134
156
|
const sub = optArray(first.options)[0];
|
|
135
157
|
const subName = sub ? str(sub.name) : undefined;
|
|
@@ -226,6 +248,7 @@ export async function handleDiscordOeCommandInteraction(
|
|
|
226
248
|
// note on why this file doesn't import eve's channel module for two integers).
|
|
227
249
|
const SUB_COMMAND = 1;
|
|
228
250
|
const SUB_COMMAND_GROUP = 2;
|
|
251
|
+
const OPTION_STRING = 3;
|
|
229
252
|
const OPTION_INTEGER = 4;
|
|
230
253
|
const OPTION_NUMBER = 10;
|
|
231
254
|
const CHAT_INPUT = 1;
|
|
@@ -299,6 +322,35 @@ export function buildOeDiscordCommandDefinition(): Record<string, unknown> {
|
|
|
299
322
|
},
|
|
300
323
|
],
|
|
301
324
|
},
|
|
325
|
+
// OPE-2534: out-of-office cover. A SUB_COMMAND_GROUP, because Discord needs a concrete subcommand: `status` is the
|
|
326
|
+
// bare `/oe cover` of Slack and Teams, and `on` takes the optional end date core's grammar reads.
|
|
327
|
+
{
|
|
328
|
+
name: "cover",
|
|
329
|
+
description: "While you are away, I answer questions people ask you, marked as cover.",
|
|
330
|
+
type: SUB_COMMAND_GROUP,
|
|
331
|
+
options: [
|
|
332
|
+
{
|
|
333
|
+
name: "on",
|
|
334
|
+
description: "Turn cover on (from your DM with me).",
|
|
335
|
+
type: SUB_COMMAND,
|
|
336
|
+
options: [{ name: "until", description: "The first day you are back, as YYYY-MM-DD.", type: OPTION_STRING, required: false }],
|
|
337
|
+
},
|
|
338
|
+
{ name: "off", description: "Turn cover off. Your recap follows in your DM.", type: SUB_COMMAND },
|
|
339
|
+
{ name: "status", description: "Show whether cover is on.", type: SUB_COMMAND },
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
// OPE-2550: the room's stop for the routine suggestions room discovery posts. A SUB_COMMAND_GROUP for the same
|
|
343
|
+
// reason as `cover`: `status` is the bare `/oe suggest` of Slack and Teams.
|
|
344
|
+
{
|
|
345
|
+
name: "suggest",
|
|
346
|
+
description: "Stop or allow my routine suggestions in this channel or thread.",
|
|
347
|
+
type: SUB_COMMAND_GROUP,
|
|
348
|
+
options: [
|
|
349
|
+
{ name: "off", description: "Stop my routine suggestions in this channel or thread.", type: SUB_COMMAND },
|
|
350
|
+
{ name: "on", description: "Allow them again (members only).", type: SUB_COMMAND },
|
|
351
|
+
{ name: "status", description: "Show whether they are on in this channel or thread.", type: SUB_COMMAND },
|
|
352
|
+
],
|
|
353
|
+
},
|
|
302
354
|
{ name: "help", description: `List the ${oeSlash()} commands.`, type: SUB_COMMAND },
|
|
303
355
|
],
|
|
304
356
|
};
|