@helyx/bot 1.4.0 → 1.5.1
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/CHANGELOG.md +33 -0
- package/dist/channel-audit-configuration.d.ts +7 -0
- package/dist/channel-audit-configuration.js +40 -0
- package/dist/channel-audit-control.d.ts +14 -0
- package/dist/channel-audit-control.js +61 -0
- package/dist/channel-audit-permissions.d.ts +56 -0
- package/dist/channel-audit-permissions.js +417 -0
- package/dist/channel-audit-routes.d.ts +4 -0
- package/dist/channel-audit-routes.js +42 -0
- package/dist/cloud-connector.js +2 -0
- package/dist/cloud-control-dispatcher.js +22 -0
- package/dist/command-registration-policy.js +1 -0
- package/dist/control-errors.d.ts +8 -0
- package/dist/control-errors.js +24 -0
- package/dist/control-server-schemas.d.ts +2 -2
- package/dist/control-server.d.ts +4 -1
- package/dist/control-server.js +18 -25
- package/dist/diagnostic-command.d.ts +76 -0
- package/dist/diagnostic-command.js +1962 -0
- package/dist/discord-channel-audit.d.ts +9 -0
- package/dist/discord-channel-audit.js +195 -0
- package/dist/discord-guild-health.d.ts +2 -2
- package/dist/discord-role-safety.d.ts +4 -0
- package/dist/discord-role-safety.js +30 -0
- package/dist/events.d.ts +2 -0
- package/dist/events.js +5 -20
- package/dist/hosted-server-moderation.d.ts +2 -2
- package/dist/index.js +30 -1
- package/dist/shard-contracts.d.ts +87 -0
- package/dist/shard-contracts.js +39 -0
- package/dist/shard-router.d.ts +2 -0
- package/dist/shard-router.js +12 -0
- package/dist/shard-supervisor.js +7 -0
- package/dist/shard-worker.d.ts +1 -0
- package/dist/shard-worker.js +38 -1
- package/documentation/reference/environment.md +1 -1
- package/package.json +7 -7
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { channelAuditModuleIdSchema, channelAuditInputSchema, channelAuditConfigurationSchema, channelAuditReportSchema, } from "@helyx/control-protocol/channel-audit";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { BotControlTransportError } from "./control-errors.js";
|
|
4
|
+
const parameters = z
|
|
5
|
+
.object({
|
|
6
|
+
guildId: z.string().regex(/^\d{17,20}$/u),
|
|
7
|
+
moduleId: channelAuditModuleIdSchema,
|
|
8
|
+
})
|
|
9
|
+
.strict();
|
|
10
|
+
const actor = z
|
|
11
|
+
.object({
|
|
12
|
+
actorUserId: z.string().regex(/^\d{17,20}$/u),
|
|
13
|
+
correlationId: z.uuid(),
|
|
14
|
+
})
|
|
15
|
+
.strict();
|
|
16
|
+
export function registerChannelAuditRoutes(server, control) {
|
|
17
|
+
server.post("/v1/guilds/:guildId/modules/:moduleId/channel-audit/config", async (request) => {
|
|
18
|
+
const input = {
|
|
19
|
+
...parameters.parse(request.params),
|
|
20
|
+
...actor.parse(request.body),
|
|
21
|
+
};
|
|
22
|
+
if (!control.readChannelAuditConfiguration)
|
|
23
|
+
throw unsupported();
|
|
24
|
+
return channelAuditConfigurationSchema.parse(await control.readChannelAuditConfiguration(input));
|
|
25
|
+
});
|
|
26
|
+
server.post("/v1/guilds/:guildId/modules/:moduleId/channel-audit/check", async (request) => {
|
|
27
|
+
const input = {
|
|
28
|
+
...parameters.parse(request.params),
|
|
29
|
+
...actor
|
|
30
|
+
.extend({ assumptions: channelAuditInputSchema })
|
|
31
|
+
.strict()
|
|
32
|
+
.parse(request.body),
|
|
33
|
+
};
|
|
34
|
+
if (!control.checkChannelAudit)
|
|
35
|
+
throw unsupported();
|
|
36
|
+
return channelAuditReportSchema.parse(await control.checkChannelAudit(input));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function unsupported() {
|
|
40
|
+
return new BotControlTransportError(503, "This bot version does not support Channel audit. Update the bot before trying again.");
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=channel-audit-routes.js.map
|
package/dist/cloud-connector.js
CHANGED
|
@@ -763,6 +763,8 @@ function connectorCapabilities() {
|
|
|
763
763
|
"guild.module.status.write.v1",
|
|
764
764
|
"guild.module.configuration.write.v1",
|
|
765
765
|
"guild.module.configuration.preview.v1",
|
|
766
|
+
"guild.module.channel-audit.config.read.v1",
|
|
767
|
+
"guild.module.channel-audit.check.v1",
|
|
766
768
|
"guild.module.permissions.write.v1",
|
|
767
769
|
"guild.module.logging.write.v1",
|
|
768
770
|
"guild.module.action.execute.v1",
|
|
@@ -121,6 +121,28 @@ export class CloudControlDispatcher {
|
|
|
121
121
|
});
|
|
122
122
|
break;
|
|
123
123
|
}
|
|
124
|
+
case "guild.module.channel-audit.config.read.v1": {
|
|
125
|
+
const input = parseOperationInput(request.operation, request.input);
|
|
126
|
+
if (!this.control.readChannelAuditConfiguration)
|
|
127
|
+
throw new Error("Channel audit is not supported by this bot version");
|
|
128
|
+
result = await this.control.readChannelAuditConfiguration({
|
|
129
|
+
...input,
|
|
130
|
+
actorUserId: requiredActor(actorUserId),
|
|
131
|
+
correlationId,
|
|
132
|
+
});
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case "guild.module.channel-audit.check.v1": {
|
|
136
|
+
const input = parseOperationInput(request.operation, request.input);
|
|
137
|
+
if (!this.control.checkChannelAudit)
|
|
138
|
+
throw new Error("Channel audit is not supported by this bot version");
|
|
139
|
+
result = await this.control.checkChannelAudit({
|
|
140
|
+
...input,
|
|
141
|
+
actorUserId: requiredActor(actorUserId),
|
|
142
|
+
correlationId,
|
|
143
|
+
});
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
124
146
|
case "guild.module.configuration.write.v1": {
|
|
125
147
|
const input = parseOperationInput(request.operation, request.input);
|
|
126
148
|
result = await this.control.updateConfiguration({
|
package/dist/control-errors.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { type PermissionMutationDenialReason } from "@helyx/core";
|
|
2
|
+
export declare function isUnknownGuildError(error: unknown): boolean;
|
|
3
|
+
export declare function isUnknownMemberError(error: unknown): boolean;
|
|
2
4
|
export declare class BotControlTransportError extends Error {
|
|
3
5
|
readonly status: 400 | 403 | 404 | 409 | 502 | 503 | 504;
|
|
4
6
|
constructor(status: 400 | 403 | 404 | 409 | 502 | 503 | 504, message: string);
|
|
5
7
|
}
|
|
8
|
+
export declare class BotControlValidationError extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
11
|
+
export declare class BotControlNotFoundError extends Error {
|
|
12
|
+
constructor(message?: string);
|
|
13
|
+
}
|
|
6
14
|
export declare class BotControlForbiddenError extends Error {
|
|
7
15
|
constructor(message?: string);
|
|
8
16
|
}
|
package/dist/control-errors.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { permissionMutationDenialMessage, } from "@helyx/core";
|
|
2
|
+
export function isUnknownGuildError(error) {
|
|
3
|
+
return (typeof error === "object" &&
|
|
4
|
+
error !== null &&
|
|
5
|
+
"code" in error &&
|
|
6
|
+
error.code === 10_004);
|
|
7
|
+
}
|
|
8
|
+
export function isUnknownMemberError(error) {
|
|
9
|
+
return (typeof error === "object" &&
|
|
10
|
+
error !== null &&
|
|
11
|
+
"code" in error &&
|
|
12
|
+
error.code === 10_007);
|
|
13
|
+
}
|
|
2
14
|
export class BotControlTransportError extends Error {
|
|
3
15
|
status;
|
|
4
16
|
constructor(status, message) {
|
|
@@ -7,6 +19,18 @@ export class BotControlTransportError extends Error {
|
|
|
7
19
|
this.name = "BotControlTransportError";
|
|
8
20
|
}
|
|
9
21
|
}
|
|
22
|
+
export class BotControlValidationError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "BotControlValidationError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class BotControlNotFoundError extends Error {
|
|
29
|
+
constructor(message = "Discord server is not available to Helyx") {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "BotControlNotFoundError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
10
34
|
export class BotControlForbiddenError extends Error {
|
|
11
35
|
constructor(message = "You no longer have permission to perform this action") {
|
|
12
36
|
super(message);
|
|
@@ -7,10 +7,10 @@ export declare const permissionState: z.ZodObject<{
|
|
|
7
7
|
version: z.ZodNumber;
|
|
8
8
|
module: z.ZodObject<{
|
|
9
9
|
preset: z.ZodEnum<{
|
|
10
|
-
custom: "custom";
|
|
11
10
|
everyone: "everyone";
|
|
12
11
|
moderators: "moderators";
|
|
13
12
|
administrators: "administrators";
|
|
13
|
+
custom: "custom";
|
|
14
14
|
}>;
|
|
15
15
|
allowedRoleIds: z.ZodArray<z.ZodString>;
|
|
16
16
|
deniedRoleIds: z.ZodArray<z.ZodString>;
|
|
@@ -21,10 +21,10 @@ export declare const permissionState: z.ZodObject<{
|
|
|
21
21
|
enabled: z.ZodBoolean;
|
|
22
22
|
policy: z.ZodObject<{
|
|
23
23
|
preset: z.ZodEnum<{
|
|
24
|
-
custom: "custom";
|
|
25
24
|
everyone: "everyone";
|
|
26
25
|
moderators: "moderators";
|
|
27
26
|
administrators: "administrators";
|
|
27
|
+
custom: "custom";
|
|
28
28
|
}>;
|
|
29
29
|
allowedRoleIds: z.ZodArray<z.ZodString>;
|
|
30
30
|
deniedRoleIds: z.ZodArray<z.ZodString>;
|
package/dist/control-server.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { type FirstTimeCommandSetupHandler } from "./first-time-command-setup.js
|
|
|
10
10
|
import { type ShardHealthSnapshot } from "./shard-contracts.js";
|
|
11
11
|
import { type DashboardPermissionState } from "./control-permissions.js";
|
|
12
12
|
export { BotControlTransportError } from "./control-errors.js";
|
|
13
|
+
import type { ChannelAuditControl } from "@helyx/sdk";
|
|
13
14
|
import { type DashboardModuleDefinition } from "./dashboard-module-projection.js";
|
|
14
15
|
import { type AvailabilityModuleCatalogue } from "./module-availability-control.js";
|
|
15
16
|
import { registerGuildCommandControlRoutes } from "./guild-command-control-routes.js";
|
|
@@ -120,7 +121,7 @@ export interface DashboardGuildAccessState extends GuildAccessPolicy {
|
|
|
120
121
|
actorHighestRolePosition: number | null;
|
|
121
122
|
updatedAt: string | null;
|
|
122
123
|
}
|
|
123
|
-
export interface BotControlHandler extends FirstTimeCommandSetupHandler, DashboardActionHandler {
|
|
124
|
+
export interface BotControlHandler extends FirstTimeCommandSetupHandler, DashboardActionHandler, ChannelAuditControl {
|
|
124
125
|
creatorOnboarding?(input: CreatorOnboardingRequest): Promise<CreatorOnboardingResponse>;
|
|
125
126
|
cleanupOAuthConsumer(input: {
|
|
126
127
|
consumerBindingId: string;
|
|
@@ -349,6 +350,8 @@ export declare class BotControlService implements BotControlHandler {
|
|
|
349
350
|
private readonly supportNotifications?;
|
|
350
351
|
private readonly presenceOwnership?;
|
|
351
352
|
private readonly presenceShardCount?;
|
|
353
|
+
readonly readChannelAuditConfiguration: Required<ChannelAuditControl>["readChannelAuditConfiguration"];
|
|
354
|
+
readonly checkChannelAudit: Required<ChannelAuditControl>["checkChannelAudit"];
|
|
352
355
|
constructor(databaseOrPersistence: DatabasePool | BotControlPersistence, modules: readonly ModuleDefinition[], resources: BotControlGuildResourceDirectory, services: ServiceAccess, privacyExecutor?: PrivacyProviderExecutor, supportNotifications?: {
|
|
353
356
|
guildId: string;
|
|
354
357
|
channelId: string;
|
package/dist/control-server.js
CHANGED
|
@@ -13,9 +13,11 @@ import { canAccessModule, PREMIUM_MODULE_UNAVAILABLE_MESSAGE, } from "./premium-
|
|
|
13
13
|
import { completeFirstTimeCommandSetup, } from "./first-time-command-setup.js";
|
|
14
14
|
import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
|
|
15
15
|
import { assertVersion, declaredManagedResourcePermission, fromDashboardPolicy, managedResourcePermissionId, permissionPoliciesEqual, permissionPolicyKey, safeOptionalVersion, safeVersion, toDashboardPolicy, } from "./control-permissions.js";
|
|
16
|
-
import { assertPermissionMutationAllowed, BotControlForbiddenError, BotControlTransportError, } from "./control-errors.js";
|
|
16
|
+
import { assertPermissionMutationAllowed, BotControlForbiddenError, BotControlNotFoundError, BotControlValidationError, BotControlTransportError, isUnknownGuildError, isUnknownMemberError, } from "./control-errors.js";
|
|
17
17
|
export { BotControlTransportError } from "./control-errors.js";
|
|
18
18
|
import { dashboardCommands } from "./dashboard-commands.js";
|
|
19
|
+
import { registerChannelAuditRoutes } from "./channel-audit-routes.js";
|
|
20
|
+
import { createChannelAuditControl } from "./channel-audit-control.js";
|
|
19
21
|
import { executeCreatorOnboarding, registerCreatorOnboardingControlRoute, } from "./creator-onboarding-control.js";
|
|
20
22
|
import { mapDashboardModule, } from "./dashboard-module-projection.js";
|
|
21
23
|
import { availabilityCatalogueDefinitions, availabilityCatalogue, dashboardModuleStatus, disabledModuleSummary, moduleAvailabilityService, moduleAvailabilityStates, statusCatalogueDefinitions, } from "./module-availability-control.js";
|
|
@@ -215,6 +217,8 @@ export class BotControlService {
|
|
|
215
217
|
#services;
|
|
216
218
|
#privacyProviders;
|
|
217
219
|
#persistence;
|
|
220
|
+
readChannelAuditConfiguration;
|
|
221
|
+
checkChannelAudit;
|
|
218
222
|
constructor(databaseOrPersistence, modules, resources, services, privacyExecutor, supportNotifications, presenceOwnership, presenceShardCount, statusModules) {
|
|
219
223
|
this.resources = resources;
|
|
220
224
|
this.supportNotifications = supportNotifications;
|
|
@@ -235,6 +239,18 @@ export class BotControlService {
|
|
|
235
239
|
this.#permissions = services.get(HELYX_SERVICE_NAMES.permissions);
|
|
236
240
|
this.#services = services;
|
|
237
241
|
this.#privacyProviders = createPrivacyProviderRegistry(modules, privacyExecutor, this.#services);
|
|
242
|
+
const audit = createChannelAuditControl({
|
|
243
|
+
modules: this.#modules,
|
|
244
|
+
resources,
|
|
245
|
+
services,
|
|
246
|
+
configurations: this.#configurations,
|
|
247
|
+
permissions: this.#permissions,
|
|
248
|
+
listModules: (guildId) => this.#persistence.repositories.installations.listModules(guildId),
|
|
249
|
+
readGuildPolicy: (guildId) => this.#readGuildAccessPolicy(guildId),
|
|
250
|
+
requireAccessible: (guildId, moduleId) => this.#requireModuleAccessible(guildId, moduleId),
|
|
251
|
+
});
|
|
252
|
+
this.readChannelAuditConfiguration = audit.readChannelAuditConfiguration;
|
|
253
|
+
this.checkChannelAudit = audit.checkChannelAudit;
|
|
238
254
|
}
|
|
239
255
|
async creatorOnboarding(input) {
|
|
240
256
|
return executeCreatorOnboarding(input, {
|
|
@@ -1493,6 +1509,7 @@ export async function createBotControlServer(input) {
|
|
|
1493
1509
|
return input.service.setModuleStatus({ ...params, ...body });
|
|
1494
1510
|
});
|
|
1495
1511
|
registerDashboardActionRoutes(server, input.service);
|
|
1512
|
+
registerChannelAuditRoutes(server, input.service);
|
|
1496
1513
|
server.get("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType", async (request) => {
|
|
1497
1514
|
const params = z
|
|
1498
1515
|
.object({
|
|
@@ -1690,31 +1707,7 @@ export function mapBotControlError(error) {
|
|
|
1690
1707
|
};
|
|
1691
1708
|
return undefined;
|
|
1692
1709
|
}
|
|
1693
|
-
class BotControlValidationError extends Error {
|
|
1694
|
-
constructor(message) {
|
|
1695
|
-
super(message);
|
|
1696
|
-
this.name = "BotControlValidationError";
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
class BotControlNotFoundError extends Error {
|
|
1700
|
-
constructor(message = "Discord server is not available to Helyx") {
|
|
1701
|
-
super(message);
|
|
1702
|
-
this.name = "BotControlNotFoundError";
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
1710
|
function digest(value) {
|
|
1706
1711
|
return createHash("sha256").update(value).digest();
|
|
1707
1712
|
}
|
|
1708
|
-
function isUnknownGuildError(error) {
|
|
1709
|
-
return (typeof error === "object" &&
|
|
1710
|
-
error !== null &&
|
|
1711
|
-
"code" in error &&
|
|
1712
|
-
error.code === 10_004);
|
|
1713
|
-
}
|
|
1714
|
-
function isUnknownMemberError(error) {
|
|
1715
|
-
return (typeof error === "object" &&
|
|
1716
|
-
error !== null &&
|
|
1717
|
-
"code" in error &&
|
|
1718
|
-
error.code === 10_007);
|
|
1719
|
-
}
|
|
1720
1713
|
//# sourceMappingURL=control-server.js.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { DatabasePool } from "@helyx/database";
|
|
2
|
+
import type { MysqlPool } from "@helyx/database-mysql";
|
|
3
|
+
import { type ModuleDefinition, type ModuleRecordService, type PermissionActor, type ServiceAccess } from "@helyx/sdk";
|
|
4
|
+
import { type Client } from "discord.js";
|
|
5
|
+
export interface DiagnosticIssue {
|
|
6
|
+
kind: "repairable" | "human" | "technical" | "incomplete";
|
|
7
|
+
message: string;
|
|
8
|
+
evidence?: Readonly<Record<string, unknown>>;
|
|
9
|
+
afterRepair?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface DiagnosticProbe {
|
|
12
|
+
id: string;
|
|
13
|
+
moduleId?: string;
|
|
14
|
+
label: string;
|
|
15
|
+
inspect(this: void): Promise<DiagnosticIssue | null>;
|
|
16
|
+
repair?(round: 1 | 2, issue: DiagnosticIssue): Promise<"attempted" | "unchanged" | "stale">;
|
|
17
|
+
verify?(issue: DiagnosticIssue): Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
export interface DiagnosticLease {
|
|
20
|
+
signal: AbortSignal;
|
|
21
|
+
assertOwned(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export type DiagnosticRunExclusive = (guildId: string, operation: (lease: DiagnosticLease) => Promise<void>) => Promise<boolean>;
|
|
24
|
+
export interface DiagnosticResources {
|
|
25
|
+
actor(userId: string): Promise<PermissionActor>;
|
|
26
|
+
message(channelId: string, messageId: string): Promise<"present" | "absent" | "unknown">;
|
|
27
|
+
channel(channelId: string, types: readonly string[], permissions: readonly string[]): Promise<DiagnosticIssue | null>;
|
|
28
|
+
role(roleId: string, managed: boolean): Promise<DiagnosticIssue | null>;
|
|
29
|
+
capabilities(): {
|
|
30
|
+
guildMembers: boolean;
|
|
31
|
+
messageContent: boolean;
|
|
32
|
+
};
|
|
33
|
+
guildCommands?(): Promise<{
|
|
34
|
+
definitionHash: string;
|
|
35
|
+
rootCount: number;
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
38
|
+
export interface DiagnosticModuleOptions {
|
|
39
|
+
modules: readonly ModuleDefinition[];
|
|
40
|
+
client?: Client;
|
|
41
|
+
resources?: (guildId: string) => DiagnosticResources;
|
|
42
|
+
runExclusive?: DiagnosticRunExclusive;
|
|
43
|
+
assertGuildAvailable?: (guildId: string) => Promise<void>;
|
|
44
|
+
listEnabledModules?: (guildId: string) => Promise<readonly {
|
|
45
|
+
moduleId: string;
|
|
46
|
+
}[]>;
|
|
47
|
+
commandStatus?: {
|
|
48
|
+
get(guildId: string): Promise<unknown>;
|
|
49
|
+
refresh(input: {
|
|
50
|
+
guildId: string;
|
|
51
|
+
actorUserId: string;
|
|
52
|
+
correlationId: string;
|
|
53
|
+
}): Promise<unknown>;
|
|
54
|
+
};
|
|
55
|
+
stageMilliseconds?: number;
|
|
56
|
+
maximumRunMilliseconds?: number;
|
|
57
|
+
}
|
|
58
|
+
export interface DiagnosticRecordEnvironment {
|
|
59
|
+
guildId: string;
|
|
60
|
+
actorUserId: string;
|
|
61
|
+
correlationId: string;
|
|
62
|
+
modules: readonly ModuleDefinition[];
|
|
63
|
+
records: ModuleRecordService;
|
|
64
|
+
services: ServiceAccess;
|
|
65
|
+
assertCanInspect(): Promise<void>;
|
|
66
|
+
assertCanMutate(moduleId: string): Promise<void>;
|
|
67
|
+
inspectMessage(channelId: string, messageId: string): Promise<"present" | "absent" | "unknown">;
|
|
68
|
+
now(): Date;
|
|
69
|
+
}
|
|
70
|
+
/** A core command, never a selectable module or a module-repair hook. */
|
|
71
|
+
export declare function createDiagnosticModule(options: DiagnosticModuleOptions): ModuleDefinition;
|
|
72
|
+
/** Reserve at most four sessions, leaving at least two pool slots for repairs. */
|
|
73
|
+
export declare function createPostgresDiagnosticLock(pool: DatabasePool): DiagnosticRunExclusive;
|
|
74
|
+
/** Reserve at most four sessions, leaving at least two pool slots for repairs. */
|
|
75
|
+
export declare function createMysqlDiagnosticLock(pool: MysqlPool): DiagnosticRunExclusive;
|
|
76
|
+
//# sourceMappingURL=diagnostic-command.d.ts.map
|