@vellumai/cli 0.11.10-dev.202609110043.1ff55a1 → 0.11.10-staging.2
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/node_modules/@vellumai/avatar-manifest/package.json +1 -2
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/package.json +1 -2
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +1 -2
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +0 -31
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/channels.ts +3 -28
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +0 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/reactions.ts +5 -85
- package/node_modules/@vellumai/service-contracts/package.json +1 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +0 -31
- package/node_modules/@vellumai/service-contracts/src/channels.ts +3 -28
- package/node_modules/@vellumai/service-contracts/src/index.ts +0 -1
- package/node_modules/@vellumai/service-contracts/src/reactions.ts +5 -85
- package/package.json +1 -1
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +0 -220
- package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +0 -139
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +0 -220
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +0 -139
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +0 -51
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/plan-credit.ts +0 -66
- package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +0 -51
- package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +0 -66
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json
CHANGED
|
@@ -26,8 +26,7 @@
|
|
|
26
26
|
"./error": "./src/error.ts",
|
|
27
27
|
"./reactions": "./src/reactions.ts",
|
|
28
28
|
"./guardian-requests": "./src/guardian-requests.ts",
|
|
29
|
-
"./platform-credential": "./src/platform-credential.ts"
|
|
30
|
-
"./plan-credit": "./src/plan-credit.ts"
|
|
29
|
+
"./platform-credential": "./src/platform-credential.ts"
|
|
31
30
|
},
|
|
32
31
|
"scripts": {
|
|
33
32
|
"typecheck": "bunx tsc --noEmit",
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
botProviderForChannel,
|
|
5
4
|
CHANNEL_BOT_PROVIDER,
|
|
6
5
|
CHANNEL_IDS,
|
|
7
|
-
channelForBotProvider,
|
|
8
6
|
isChannelBotProvider,
|
|
9
7
|
isChannelUserIntegration,
|
|
10
8
|
isChannelId,
|
|
@@ -43,35 +41,6 @@ describe("isChannelId", () => {
|
|
|
43
41
|
});
|
|
44
42
|
});
|
|
45
43
|
|
|
46
|
-
describe("botProviderForChannel and channelForBotProvider", () => {
|
|
47
|
-
test("are inverses over every entry of the map", () => {
|
|
48
|
-
for (const [channelId, botProviderKey] of Object.entries(
|
|
49
|
-
CHANNEL_BOT_PROVIDER,
|
|
50
|
-
)) {
|
|
51
|
-
expect(botProviderForChannel(channelId)).toBe(botProviderKey);
|
|
52
|
-
expect(channelForBotProvider(botProviderKey)).toBe(
|
|
53
|
-
channelId as keyof typeof CHANNEL_BOT_PROVIDER,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
|
|
59
|
-
expect(botProviderForChannel("phone")).toBeUndefined();
|
|
60
|
-
expect(botProviderForChannel("vellum")).toBeUndefined();
|
|
61
|
-
expect(botProviderForChannel("not-a-channel")).toBeUndefined();
|
|
62
|
-
// `slack` is the person's integration standing beside the bot's key.
|
|
63
|
-
expect(channelForBotProvider("slack")).toBeUndefined();
|
|
64
|
-
expect(channelForBotProvider("google")).toBeUndefined();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("never resolve an inherited object property as a channel or a key", () => {
|
|
68
|
-
for (const name of ["constructor", "toString", "__proto__"]) {
|
|
69
|
-
expect(botProviderForChannel(name)).toBeUndefined();
|
|
70
|
-
expect(channelForBotProvider(name)).toBeUndefined();
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
44
|
describe("isChannelUserIntegration", () => {
|
|
76
45
|
test("names the grant standing beside a bot of the same brand", () => {
|
|
77
46
|
expect(isChannelUserIntegration("slack")).toBe(true);
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/channels.ts
CHANGED
|
@@ -93,34 +93,9 @@ export const CHANNEL_BOT_PROVIDER = {
|
|
|
93
93
|
* connected" question, for any provider key.
|
|
94
94
|
*/
|
|
95
95
|
export function isChannelBotProvider(providerKey: string): boolean {
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The provider key holding a channel's bot credential, or `undefined` when
|
|
101
|
-
* the channel has none, or is not a channel at all. Walks the map's own
|
|
102
|
-
* entries, so an inherited object property name is never a channel.
|
|
103
|
-
*/
|
|
104
|
-
export function botProviderForChannel(channelId: string): string | undefined {
|
|
105
|
-
return Object.entries(CHANNEL_BOT_PROVIDER).find(
|
|
106
|
-
([id]) => id === channelId,
|
|
107
|
-
)?.[1];
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* The channel whose bot credential a provider key holds, or `undefined` when
|
|
112
|
-
* the key is not a bot's. The inverse of `botProviderForChannel`, for a
|
|
113
|
-
* surface that has only the provider key in hand and needs to speak in the
|
|
114
|
-
* channel's vocabulary.
|
|
115
|
-
*/
|
|
116
|
-
export function channelForBotProvider(
|
|
117
|
-
providerKey: string,
|
|
118
|
-
): keyof typeof CHANNEL_BOT_PROVIDER | undefined {
|
|
119
|
-
return (
|
|
120
|
-
Object.entries(CHANNEL_BOT_PROVIDER) as Array<
|
|
121
|
-
[keyof typeof CHANNEL_BOT_PROVIDER, string]
|
|
122
|
-
>
|
|
123
|
-
).find(([, key]) => key === providerKey)?.[0];
|
|
96
|
+
return (Object.values(CHANNEL_BOT_PROVIDER) as readonly string[]).includes(
|
|
97
|
+
providerKey,
|
|
98
|
+
);
|
|
124
99
|
}
|
|
125
100
|
|
|
126
101
|
/**
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts
CHANGED
|
@@ -28,7 +28,6 @@ export * from "./rpc.js";
|
|
|
28
28
|
export * from "./trust-rules.js";
|
|
29
29
|
export * from "./ingress.js";
|
|
30
30
|
export * from "./no-response.js";
|
|
31
|
-
export * from "./plan-credit.js";
|
|
32
31
|
export * from "./platform-credential.js";
|
|
33
32
|
export * from "./remote-web-pairing.js";
|
|
34
33
|
export * from "./twilio-ingress.js";
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/reactions.ts
CHANGED
|
@@ -7,13 +7,11 @@
|
|
|
7
7
|
* is spelled. Modelled on Zulip's `reaction_type`, the one surveyed system
|
|
8
8
|
* that separates the namespace from the name.
|
|
9
9
|
*
|
|
10
|
-
* `shortcode` is a name in a channel's own namespace
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* a stand-in for an unknown one, and nothing past an adapter needs a
|
|
16
|
-
* channel's naming to read it.
|
|
10
|
+
* `shortcode` is a name in a channel's own namespace whose kind the channel
|
|
11
|
+
* does not disclose: Slack sends `+1` for the standard emoji and `blob_wave`
|
|
12
|
+
* for a workspace upload with nothing to tell them apart, and only the
|
|
13
|
+
* workspace token can resolve the second. It is a distinct kind from
|
|
14
|
+
* `unicode`, not a stand-in for an unknown one.
|
|
17
15
|
*/
|
|
18
16
|
import { z } from "zod";
|
|
19
17
|
|
|
@@ -41,12 +39,6 @@ export const ReactionEmojiFieldsSchema = z.object({
|
|
|
41
39
|
});
|
|
42
40
|
export type ReactionEmojiFields = z.infer<typeof ReactionEmojiFieldsSchema>;
|
|
43
41
|
|
|
44
|
-
/** The typed fields with the kind and name known. */
|
|
45
|
-
export type ReactionEmojiIdentity = ReactionEmojiFields & {
|
|
46
|
-
emojiKind: ReactionEmojiKind;
|
|
47
|
-
emojiName: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
42
|
/**
|
|
51
43
|
* The typed emoji fields a source actually carries, with undefined ones
|
|
52
44
|
* omitted: an absent key and a present-but-undefined one serialize alike, but the
|
|
@@ -66,75 +58,3 @@ export function pickReactionEmojiFields(
|
|
|
66
58
|
: {}),
|
|
67
59
|
};
|
|
68
60
|
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Parse Discord's custom-emoji mention form. The form travels the wire as a
|
|
72
|
-
* reaction's spelling, so more than one package reads it: the daemon rebuilds
|
|
73
|
-
* a REST path from it when the assistant reacts, and a row carrying only the
|
|
74
|
-
* spelling recovers its kind from it. One parser, so the two cannot disagree
|
|
75
|
-
* about what counts as one.
|
|
76
|
-
*
|
|
77
|
-
* `animated` reports whether the spelling carries the `a` marker. Discord's
|
|
78
|
-
* normalizer never writes that marker, so it is read only from spellings that
|
|
79
|
-
* arrive from elsewhere: a row written by another producer, or a value the
|
|
80
|
-
* model hands back as it received it.
|
|
81
|
-
*/
|
|
82
|
-
export function parseDiscordEmojiMention(
|
|
83
|
-
emoji: string,
|
|
84
|
-
): { name: string; id: string; animated: boolean } | null {
|
|
85
|
-
const match = /^<(a?):([^:>]+):(\d+)>$/.exec(emoji);
|
|
86
|
-
return match
|
|
87
|
-
? { name: match[2]!, id: match[3]!, animated: match[1] === "a" }
|
|
88
|
-
: null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Recover an emoji's kind from its spelling alone. This is the one inference
|
|
93
|
-
* the design permits, reserved for a value that carries the string and no
|
|
94
|
-
* typed fields: a persisted row or a replayed retry payload. A value that
|
|
95
|
-
* declares its kind never reaches this; `reactionEmojiIdentity` makes that
|
|
96
|
-
* choice for every reader.
|
|
97
|
-
*
|
|
98
|
-
* A mention form is unambiguous. Past that the two remaining kinds are told
|
|
99
|
-
* apart by whether the string is a name at all: a channel's shortcode is
|
|
100
|
-
* ASCII word characters, optionally carrying Slack's `::skin-tone-N`
|
|
101
|
-
* suffix, and anything else is the character itself.
|
|
102
|
-
*/
|
|
103
|
-
export function classifyReactionEmojiSpelling(
|
|
104
|
-
emoji: string,
|
|
105
|
-
): ReactionEmojiIdentity {
|
|
106
|
-
const custom = parseDiscordEmojiMention(emoji);
|
|
107
|
-
if (custom) {
|
|
108
|
-
// The plain `<:name:id>` form says nothing about animation: the
|
|
109
|
-
// normalizer spells every custom emoji that way and reports animation
|
|
110
|
-
// in the typed field instead, so a spelling without the `a` marker is
|
|
111
|
-
// "unrecorded", not "not animated". Only the `<a:` form asserts it.
|
|
112
|
-
return {
|
|
113
|
-
emojiKind: "custom",
|
|
114
|
-
emojiName: custom.name,
|
|
115
|
-
emojiId: custom.id,
|
|
116
|
-
...(custom.animated ? { emojiAnimated: true } : {}),
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
return /^[\w+-]+(::skin-tone-[2-6])?$/.test(emoji)
|
|
120
|
-
? { emojiKind: "shortcode", emojiName: emoji }
|
|
121
|
-
: { emojiKind: "unicode", emojiName: emoji };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* The typed identity of a reaction's emoji: the fields it declares when it
|
|
126
|
-
* declares a kind, otherwise the kind recovered from its spelling. Every
|
|
127
|
-
* reader that must know what an emoji is goes through this, so the wire
|
|
128
|
-
* contract and the web agree on when the spelling is consulted.
|
|
129
|
-
*/
|
|
130
|
-
export function reactionEmojiIdentity(
|
|
131
|
-
reaction: { emoji: string } & ReactionEmojiFields,
|
|
132
|
-
): ReactionEmojiIdentity {
|
|
133
|
-
return reaction.emojiKind !== undefined && reaction.emojiName !== undefined
|
|
134
|
-
? {
|
|
135
|
-
...pickReactionEmojiFields(reaction),
|
|
136
|
-
emojiKind: reaction.emojiKind,
|
|
137
|
-
emojiName: reaction.emojiName,
|
|
138
|
-
}
|
|
139
|
-
: classifyReactionEmojiSpelling(reaction.emoji);
|
|
140
|
-
}
|
|
@@ -26,8 +26,7 @@
|
|
|
26
26
|
"./error": "./src/error.ts",
|
|
27
27
|
"./reactions": "./src/reactions.ts",
|
|
28
28
|
"./guardian-requests": "./src/guardian-requests.ts",
|
|
29
|
-
"./platform-credential": "./src/platform-credential.ts"
|
|
30
|
-
"./plan-credit": "./src/plan-credit.ts"
|
|
29
|
+
"./platform-credential": "./src/platform-credential.ts"
|
|
31
30
|
},
|
|
32
31
|
"scripts": {
|
|
33
32
|
"typecheck": "bunx tsc --noEmit",
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
botProviderForChannel,
|
|
5
4
|
CHANNEL_BOT_PROVIDER,
|
|
6
5
|
CHANNEL_IDS,
|
|
7
|
-
channelForBotProvider,
|
|
8
6
|
isChannelBotProvider,
|
|
9
7
|
isChannelUserIntegration,
|
|
10
8
|
isChannelId,
|
|
@@ -43,35 +41,6 @@ describe("isChannelId", () => {
|
|
|
43
41
|
});
|
|
44
42
|
});
|
|
45
43
|
|
|
46
|
-
describe("botProviderForChannel and channelForBotProvider", () => {
|
|
47
|
-
test("are inverses over every entry of the map", () => {
|
|
48
|
-
for (const [channelId, botProviderKey] of Object.entries(
|
|
49
|
-
CHANNEL_BOT_PROVIDER,
|
|
50
|
-
)) {
|
|
51
|
-
expect(botProviderForChannel(channelId)).toBe(botProviderKey);
|
|
52
|
-
expect(channelForBotProvider(botProviderKey)).toBe(
|
|
53
|
-
channelId as keyof typeof CHANNEL_BOT_PROVIDER,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
|
|
59
|
-
expect(botProviderForChannel("phone")).toBeUndefined();
|
|
60
|
-
expect(botProviderForChannel("vellum")).toBeUndefined();
|
|
61
|
-
expect(botProviderForChannel("not-a-channel")).toBeUndefined();
|
|
62
|
-
// `slack` is the person's integration standing beside the bot's key.
|
|
63
|
-
expect(channelForBotProvider("slack")).toBeUndefined();
|
|
64
|
-
expect(channelForBotProvider("google")).toBeUndefined();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("never resolve an inherited object property as a channel or a key", () => {
|
|
68
|
-
for (const name of ["constructor", "toString", "__proto__"]) {
|
|
69
|
-
expect(botProviderForChannel(name)).toBeUndefined();
|
|
70
|
-
expect(channelForBotProvider(name)).toBeUndefined();
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
44
|
describe("isChannelUserIntegration", () => {
|
|
76
45
|
test("names the grant standing beside a bot of the same brand", () => {
|
|
77
46
|
expect(isChannelUserIntegration("slack")).toBe(true);
|
|
@@ -93,34 +93,9 @@ export const CHANNEL_BOT_PROVIDER = {
|
|
|
93
93
|
* connected" question, for any provider key.
|
|
94
94
|
*/
|
|
95
95
|
export function isChannelBotProvider(providerKey: string): boolean {
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The provider key holding a channel's bot credential, or `undefined` when
|
|
101
|
-
* the channel has none, or is not a channel at all. Walks the map's own
|
|
102
|
-
* entries, so an inherited object property name is never a channel.
|
|
103
|
-
*/
|
|
104
|
-
export function botProviderForChannel(channelId: string): string | undefined {
|
|
105
|
-
return Object.entries(CHANNEL_BOT_PROVIDER).find(
|
|
106
|
-
([id]) => id === channelId,
|
|
107
|
-
)?.[1];
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* The channel whose bot credential a provider key holds, or `undefined` when
|
|
112
|
-
* the key is not a bot's. The inverse of `botProviderForChannel`, for a
|
|
113
|
-
* surface that has only the provider key in hand and needs to speak in the
|
|
114
|
-
* channel's vocabulary.
|
|
115
|
-
*/
|
|
116
|
-
export function channelForBotProvider(
|
|
117
|
-
providerKey: string,
|
|
118
|
-
): keyof typeof CHANNEL_BOT_PROVIDER | undefined {
|
|
119
|
-
return (
|
|
120
|
-
Object.entries(CHANNEL_BOT_PROVIDER) as Array<
|
|
121
|
-
[keyof typeof CHANNEL_BOT_PROVIDER, string]
|
|
122
|
-
>
|
|
123
|
-
).find(([, key]) => key === providerKey)?.[0];
|
|
96
|
+
return (Object.values(CHANNEL_BOT_PROVIDER) as readonly string[]).includes(
|
|
97
|
+
providerKey,
|
|
98
|
+
);
|
|
124
99
|
}
|
|
125
100
|
|
|
126
101
|
/**
|
|
@@ -28,7 +28,6 @@ export * from "./rpc.js";
|
|
|
28
28
|
export * from "./trust-rules.js";
|
|
29
29
|
export * from "./ingress.js";
|
|
30
30
|
export * from "./no-response.js";
|
|
31
|
-
export * from "./plan-credit.js";
|
|
32
31
|
export * from "./platform-credential.js";
|
|
33
32
|
export * from "./remote-web-pairing.js";
|
|
34
33
|
export * from "./twilio-ingress.js";
|
|
@@ -7,13 +7,11 @@
|
|
|
7
7
|
* is spelled. Modelled on Zulip's `reaction_type`, the one surveyed system
|
|
8
8
|
* that separates the namespace from the name.
|
|
9
9
|
*
|
|
10
|
-
* `shortcode` is a name in a channel's own namespace
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* a stand-in for an unknown one, and nothing past an adapter needs a
|
|
16
|
-
* channel's naming to read it.
|
|
10
|
+
* `shortcode` is a name in a channel's own namespace whose kind the channel
|
|
11
|
+
* does not disclose: Slack sends `+1` for the standard emoji and `blob_wave`
|
|
12
|
+
* for a workspace upload with nothing to tell them apart, and only the
|
|
13
|
+
* workspace token can resolve the second. It is a distinct kind from
|
|
14
|
+
* `unicode`, not a stand-in for an unknown one.
|
|
17
15
|
*/
|
|
18
16
|
import { z } from "zod";
|
|
19
17
|
|
|
@@ -41,12 +39,6 @@ export const ReactionEmojiFieldsSchema = z.object({
|
|
|
41
39
|
});
|
|
42
40
|
export type ReactionEmojiFields = z.infer<typeof ReactionEmojiFieldsSchema>;
|
|
43
41
|
|
|
44
|
-
/** The typed fields with the kind and name known. */
|
|
45
|
-
export type ReactionEmojiIdentity = ReactionEmojiFields & {
|
|
46
|
-
emojiKind: ReactionEmojiKind;
|
|
47
|
-
emojiName: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
42
|
/**
|
|
51
43
|
* The typed emoji fields a source actually carries, with undefined ones
|
|
52
44
|
* omitted: an absent key and a present-but-undefined one serialize alike, but the
|
|
@@ -66,75 +58,3 @@ export function pickReactionEmojiFields(
|
|
|
66
58
|
: {}),
|
|
67
59
|
};
|
|
68
60
|
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Parse Discord's custom-emoji mention form. The form travels the wire as a
|
|
72
|
-
* reaction's spelling, so more than one package reads it: the daemon rebuilds
|
|
73
|
-
* a REST path from it when the assistant reacts, and a row carrying only the
|
|
74
|
-
* spelling recovers its kind from it. One parser, so the two cannot disagree
|
|
75
|
-
* about what counts as one.
|
|
76
|
-
*
|
|
77
|
-
* `animated` reports whether the spelling carries the `a` marker. Discord's
|
|
78
|
-
* normalizer never writes that marker, so it is read only from spellings that
|
|
79
|
-
* arrive from elsewhere: a row written by another producer, or a value the
|
|
80
|
-
* model hands back as it received it.
|
|
81
|
-
*/
|
|
82
|
-
export function parseDiscordEmojiMention(
|
|
83
|
-
emoji: string,
|
|
84
|
-
): { name: string; id: string; animated: boolean } | null {
|
|
85
|
-
const match = /^<(a?):([^:>]+):(\d+)>$/.exec(emoji);
|
|
86
|
-
return match
|
|
87
|
-
? { name: match[2]!, id: match[3]!, animated: match[1] === "a" }
|
|
88
|
-
: null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Recover an emoji's kind from its spelling alone. This is the one inference
|
|
93
|
-
* the design permits, reserved for a value that carries the string and no
|
|
94
|
-
* typed fields: a persisted row or a replayed retry payload. A value that
|
|
95
|
-
* declares its kind never reaches this; `reactionEmojiIdentity` makes that
|
|
96
|
-
* choice for every reader.
|
|
97
|
-
*
|
|
98
|
-
* A mention form is unambiguous. Past that the two remaining kinds are told
|
|
99
|
-
* apart by whether the string is a name at all: a channel's shortcode is
|
|
100
|
-
* ASCII word characters, optionally carrying Slack's `::skin-tone-N`
|
|
101
|
-
* suffix, and anything else is the character itself.
|
|
102
|
-
*/
|
|
103
|
-
export function classifyReactionEmojiSpelling(
|
|
104
|
-
emoji: string,
|
|
105
|
-
): ReactionEmojiIdentity {
|
|
106
|
-
const custom = parseDiscordEmojiMention(emoji);
|
|
107
|
-
if (custom) {
|
|
108
|
-
// The plain `<:name:id>` form says nothing about animation: the
|
|
109
|
-
// normalizer spells every custom emoji that way and reports animation
|
|
110
|
-
// in the typed field instead, so a spelling without the `a` marker is
|
|
111
|
-
// "unrecorded", not "not animated". Only the `<a:` form asserts it.
|
|
112
|
-
return {
|
|
113
|
-
emojiKind: "custom",
|
|
114
|
-
emojiName: custom.name,
|
|
115
|
-
emojiId: custom.id,
|
|
116
|
-
...(custom.animated ? { emojiAnimated: true } : {}),
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
return /^[\w+-]+(::skin-tone-[2-6])?$/.test(emoji)
|
|
120
|
-
? { emojiKind: "shortcode", emojiName: emoji }
|
|
121
|
-
: { emojiKind: "unicode", emojiName: emoji };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* The typed identity of a reaction's emoji: the fields it declares when it
|
|
126
|
-
* declares a kind, otherwise the kind recovered from its spelling. Every
|
|
127
|
-
* reader that must know what an emoji is goes through this, so the wire
|
|
128
|
-
* contract and the web agree on when the spelling is consulted.
|
|
129
|
-
*/
|
|
130
|
-
export function reactionEmojiIdentity(
|
|
131
|
-
reaction: { emoji: string } & ReactionEmojiFields,
|
|
132
|
-
): ReactionEmojiIdentity {
|
|
133
|
-
return reaction.emojiKind !== undefined && reaction.emojiName !== undefined
|
|
134
|
-
? {
|
|
135
|
-
...pickReactionEmojiFields(reaction),
|
|
136
|
-
emojiKind: reaction.emojiKind,
|
|
137
|
-
emojiName: reaction.emojiName,
|
|
138
|
-
}
|
|
139
|
-
: classifyReactionEmojiSpelling(reaction.emoji);
|
|
140
|
-
}
|
package/package.json
CHANGED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
NOTIFICATION_AVATAR_FALLBACK_DISC_HEX,
|
|
5
|
-
NOTIFICATION_AVATAR_INSET,
|
|
6
|
-
NOTIFICATION_AVATAR_MAX_BYTES,
|
|
7
|
-
NOTIFICATION_AVATAR_MAX_LOCAL_BYTES,
|
|
8
|
-
NOTIFICATION_AVATAR_SIZE,
|
|
9
|
-
NOTIFICATION_AVATAR_SPEC_VERSION,
|
|
10
|
-
notificationAvatarDiscHex,
|
|
11
|
-
notificationAvatarGeometry,
|
|
12
|
-
notificationAvatarSvg,
|
|
13
|
-
} from "../notification-avatar.js";
|
|
14
|
-
|
|
15
|
-
function allAttributes(tag: string, svg: string): Record<string, string>[] {
|
|
16
|
-
return [...svg.matchAll(new RegExp(`<${tag}\\s([^>]*)/>`, "g"))].map(
|
|
17
|
-
(match) => {
|
|
18
|
-
const out: Record<string, string> = {};
|
|
19
|
-
for (const [, name, value] of match[1]!.matchAll(
|
|
20
|
-
/([\w:-]+)="([^"]*)"/g,
|
|
21
|
-
)) {
|
|
22
|
-
out[name!] = value!;
|
|
23
|
-
}
|
|
24
|
-
return out;
|
|
25
|
-
},
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function attributes(tag: string, svg: string): Record<string, string> {
|
|
30
|
-
const all = allAttributes(tag, svg);
|
|
31
|
-
expect(all.length).toBeGreaterThan(0);
|
|
32
|
-
return all[0]!;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function countOf(tag: string, svg: string): number {
|
|
36
|
-
return svg.split(`<${tag}`).length - 1;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const PNG_BASE64 = "iVBORw0KGgo=";
|
|
40
|
-
|
|
41
|
-
describe("notificationAvatarDiscHex", () => {
|
|
42
|
-
test("mixes the accent into white", () => {
|
|
43
|
-
expect(notificationAvatarDiscHex("#E9642F")).toBe("#FCE9E2");
|
|
44
|
-
expect(notificationAvatarDiscHex("#4C9B50")).toBe("#E6F1E7");
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("is case insensitive and always returns uppercase #RRGGBB", () => {
|
|
48
|
-
expect(notificationAvatarDiscHex("#e9642f")).toBe("#FCE9E2");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("keeps white white and lifts black to near white", () => {
|
|
52
|
-
expect(notificationAvatarDiscHex("#FFFFFF")).toBe("#FFFFFF");
|
|
53
|
-
expect(notificationAvatarDiscHex("#000000")).toBe("#DBDBDB");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test.each([
|
|
57
|
-
["null", null],
|
|
58
|
-
["short form", "#abc"],
|
|
59
|
-
["no hash", "e9642f"],
|
|
60
|
-
["garbage", "not a colour"],
|
|
61
|
-
["empty", ""],
|
|
62
|
-
])("falls back to the neutral disc for %s", (_label, value) => {
|
|
63
|
-
expect(notificationAvatarDiscHex(value as string | null)).toBe(
|
|
64
|
-
NOTIFICATION_AVATAR_FALLBACK_DISC_HEX,
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe("notificationAvatarSvg", () => {
|
|
70
|
-
test("draws one disc and one inset avatar at the default size", () => {
|
|
71
|
-
const svg = notificationAvatarSvg({
|
|
72
|
-
innerPngBase64: PNG_BASE64,
|
|
73
|
-
accentHex: "#E9642F",
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
expect(countOf("svg", svg)).toBe(1);
|
|
77
|
-
expect(countOf("image", svg)).toBe(1);
|
|
78
|
-
|
|
79
|
-
expect(svg.startsWith("<svg ")).toBe(true);
|
|
80
|
-
expect(svg).toContain(`width="256" height="256"`);
|
|
81
|
-
expect(svg).toContain(`viewBox="0 0 256 256"`);
|
|
82
|
-
|
|
83
|
-
const circle = attributes("circle", svg);
|
|
84
|
-
expect(circle).toMatchObject({ cx: "128", cy: "128", r: "128" });
|
|
85
|
-
expect(circle.fill).toBe("#FCE9E2");
|
|
86
|
-
|
|
87
|
-
const inset = NOTIFICATION_AVATAR_SIZE * NOTIFICATION_AVATAR_INSET;
|
|
88
|
-
const inner = NOTIFICATION_AVATAR_SIZE - 2 * inset;
|
|
89
|
-
const image = attributes("image", svg);
|
|
90
|
-
expect(Number(image.x)).toBeCloseTo(inset, 3);
|
|
91
|
-
expect(Number(image.y)).toBeCloseTo(inset, 3);
|
|
92
|
-
expect(Number(image.width)).toBeCloseTo(inner, 3);
|
|
93
|
-
expect(Number(image.height)).toBeCloseTo(inner, 3);
|
|
94
|
-
expect(image.href).toBe(`data:image/png;base64,${PNG_BASE64}`);
|
|
95
|
-
expect(image["xlink:href"]).toBe(image.href);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("cover-crops a non-square raster and clips it to the disc", () => {
|
|
99
|
-
// The builder never sees the source dimensions, so what makes a 1024x512
|
|
100
|
-
// upload fill the square instead of letterboxing is the pair of attributes
|
|
101
|
-
// asserted here.
|
|
102
|
-
const svg = notificationAvatarSvg({
|
|
103
|
-
innerPngBase64: PNG_BASE64,
|
|
104
|
-
accentHex: "#E9642F",
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
const image = attributes("image", svg);
|
|
108
|
-
expect(image.preserveAspectRatio).toBe("xMidYMid slice");
|
|
109
|
-
|
|
110
|
-
const clipRef = image["clip-path"]!;
|
|
111
|
-
const clipId = clipRef.match(/^url\(#([\w-]+)\)$/)?.[1];
|
|
112
|
-
expect(clipId).toBeTruthy();
|
|
113
|
-
expect(svg).toContain(`<clipPath id="${clipId}">`);
|
|
114
|
-
|
|
115
|
-
// The clip circle carries the disc geometry and nothing else, so the
|
|
116
|
-
// painted disc and the boundary can never drift apart.
|
|
117
|
-
const [disc, clip] = allAttributes("circle", svg);
|
|
118
|
-
expect(clip).toEqual({ cx: "128", cy: "128", r: "128" });
|
|
119
|
-
expect(disc).toMatchObject({ cx: "128", cy: "128", r: "128" });
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("scales the geometry to a custom size", () => {
|
|
123
|
-
const svg = notificationAvatarSvg({
|
|
124
|
-
innerPngBase64: PNG_BASE64,
|
|
125
|
-
accentHex: "#E9642F",
|
|
126
|
-
size: 100,
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
expect(svg).toContain(`width="100" height="100"`);
|
|
130
|
-
expect(attributes("circle", svg)).toMatchObject({
|
|
131
|
-
cx: "50",
|
|
132
|
-
cy: "50",
|
|
133
|
-
r: "50",
|
|
134
|
-
});
|
|
135
|
-
expect(attributes("image", svg)).toMatchObject({
|
|
136
|
-
x: "11",
|
|
137
|
-
y: "11",
|
|
138
|
-
width: "78",
|
|
139
|
-
height: "78",
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test("carries a non-PNG inner raster with its own media type", () => {
|
|
144
|
-
const svg = notificationAvatarSvg({
|
|
145
|
-
innerPngBase64: PNG_BASE64,
|
|
146
|
-
innerMediaType: "image/jpeg",
|
|
147
|
-
accentHex: "#E9642F",
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
const image = attributes("image", svg);
|
|
151
|
-
expect(image.href).toBe(`data:image/jpeg;base64,${PNG_BASE64}`);
|
|
152
|
-
expect(image["xlink:href"]).toBe(image.href);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test("paints the fallback disc when there is no accent", () => {
|
|
156
|
-
const svg = notificationAvatarSvg({
|
|
157
|
-
innerPngBase64: PNG_BASE64,
|
|
158
|
-
accentHex: null,
|
|
159
|
-
});
|
|
160
|
-
expect(attributes("circle", svg).fill).toBe(
|
|
161
|
-
NOTIFICATION_AVATAR_FALLBACK_DISC_HEX,
|
|
162
|
-
);
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe("notificationAvatarGeometry", () => {
|
|
167
|
-
test("derives the disc, the border and the avatar edge from the size", () => {
|
|
168
|
-
expect(notificationAvatarGeometry(100)).toEqual({
|
|
169
|
-
radius: 50,
|
|
170
|
-
offset: 11,
|
|
171
|
-
inner: 78,
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("measures the default size when given none", () => {
|
|
176
|
-
expect(notificationAvatarGeometry()).toEqual(
|
|
177
|
-
notificationAvatarGeometry(NOTIFICATION_AVATAR_SIZE),
|
|
178
|
-
);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test("is the geometry the SVG is drawn with", () => {
|
|
182
|
-
const size = 100;
|
|
183
|
-
const { radius, offset, inner } = notificationAvatarGeometry(size);
|
|
184
|
-
const svg = notificationAvatarSvg({
|
|
185
|
-
innerPngBase64: PNG_BASE64,
|
|
186
|
-
accentHex: null,
|
|
187
|
-
size,
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
expect(attributes("circle", svg)).toMatchObject({
|
|
191
|
-
cx: String(radius),
|
|
192
|
-
cy: String(radius),
|
|
193
|
-
r: String(radius),
|
|
194
|
-
});
|
|
195
|
-
expect(attributes("image", svg)).toMatchObject({
|
|
196
|
-
x: String(offset),
|
|
197
|
-
y: String(offset),
|
|
198
|
-
width: String(inner),
|
|
199
|
-
height: String(inner),
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
describe("the transport contract", () => {
|
|
205
|
-
test("caps a notification PNG at 128 KB for the platform sync", () => {
|
|
206
|
-
expect(NOTIFICATION_AVATAR_MAX_BYTES).toBe(128 * 1024);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test("gives the local desktop path a looser cap of its own", () => {
|
|
210
|
-
expect(NOTIFICATION_AVATAR_MAX_LOCAL_BYTES).toBe(512 * 1024);
|
|
211
|
-
expect(NOTIFICATION_AVATAR_MAX_LOCAL_BYTES).toBeGreaterThan(
|
|
212
|
-
NOTIFICATION_AVATAR_MAX_BYTES,
|
|
213
|
-
);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
test("stamps the drawing with a spec version", () => {
|
|
217
|
-
expect(Number.isInteger(NOTIFICATION_AVATAR_SPEC_VERSION)).toBe(true);
|
|
218
|
-
expect(NOTIFICATION_AVATAR_SPEC_VERSION).toBeGreaterThan(0);
|
|
219
|
-
});
|
|
220
|
-
});
|