@yaebal/filters 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 neverlane
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @yaebal/filters
2
+
3
+ composable, type-narrowing update filters for `composer.filter(...)`. a filter is a type guard that may also attach data to the context; combine them with `and` / `or` / `not`.
4
+
5
+ ## install
6
+
7
+ ```sh
8
+ pnpm add @yaebal/filters
9
+ ```
10
+
11
+ ## usage
12
+
13
+ ```ts
14
+ import { and, command, isPrivate, mediaType, or, regex } from "@yaebal/filters";
15
+
16
+ // command filter: exposes ctx.command and ctx.args
17
+ bot.filter(command("buy"), (ctx) => ctx.reply(`args: ${ctx.args.join(", ")}`));
18
+
19
+ // regex filter: exposes ctx.match
20
+ bot.filter(regex(/^\d+$/), (ctx) => ctx.reply(`number: ${ctx.match[0]}`));
21
+
22
+ // combinators
23
+ bot.filter(and(isPrivate, command("secret")), (ctx) => ctx.reply("shhh"));
24
+
25
+ bot.filter(
26
+ or(mediaType("photo"), mediaType("video")),
27
+ (ctx) => ctx.reply("got media"),
28
+ );
29
+ ```
30
+
31
+ ---
32
+
33
+ part of [**yaebal**](https://github.com/neverlane/yaebal) — a type-safe, runtime-agnostic Telegram Bot API framework. MIT.
package/lib/index.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type { Context, Filter } from "@yaebal/core";
2
+ /** message has non-empty text. */
3
+ export declare const text: Filter<Context, {
4
+ text: string;
5
+ }>;
6
+ /** text matches `re`; exposes `ctx.match` (RegExpMatchArray). */
7
+ export declare function regex(re: RegExp): Filter<Context, {
8
+ match: RegExpMatchArray;
9
+ }>;
10
+ /** a `/command` (optionally a specific name); exposes `ctx.command` and `ctx.args`. */
11
+ export declare function command(name?: string): Filter<Context, {
12
+ command: string;
13
+ args: string[];
14
+ }>;
15
+ /** chat is one of the given types (`private`, `group`, `supergroup`, `channel`). */
16
+ export declare function chatType(...types: string[]): Filter<Context>;
17
+ export declare const isPrivate: Filter<Context>;
18
+ export declare const isGroup: Filter<Context>;
19
+ /** update is from one of the given user ids. */
20
+ export declare function fromUser(...ids: number[]): Filter<Context>;
21
+ /** update is in one of the given chat ids. */
22
+ export declare function chatId(...ids: number[]): Filter<Context>;
23
+ /** message carries one of the given media kinds (`photo`, `video`, …). */
24
+ export declare function mediaType(...kinds: string[]): Filter<Context>;
25
+ /** message carries any media. */
26
+ export declare const media: Filter<Context>;
27
+ /** message contains an entity of the given type (`url`, `mention`, `hashtag`, …). */
28
+ export declare function hasEntity(type: string): Filter<Context>;
29
+ /** matches when every filter matches; the additions intersect. */
30
+ export declare function and<A extends object, B extends object>(a: Filter<Context, A>, b: Filter<Context, B>): Filter<Context, A & B>;
31
+ export declare function and<A extends object, B extends object, D extends object>(a: Filter<Context, A>, b: Filter<Context, B>, c: Filter<Context, D>): Filter<Context, A & B & D>;
32
+ export declare function and(...filters: Filter<Context, object>[]): Filter<Context>;
33
+ /** matches when any filter matches. no additions (the matched branch is unknown). */
34
+ export declare function or(...filters: Filter<Context, object>[]): Filter<Context>;
35
+ /** matches when the filter does NOT match. no additions. */
36
+ export declare function not(filter: Filter<Context, object>): Filter<Context>;
37
+ /** everything under one namespace, mtcute-style: `filters.command(...)`, `filters.and(...)`. */
38
+ export declare const filters: {
39
+ text: Filter<Context, {
40
+ text: string;
41
+ }>;
42
+ regex: typeof regex;
43
+ command: typeof command;
44
+ chatType: typeof chatType;
45
+ isPrivate: Filter<Context, Record<never, never>>;
46
+ isGroup: Filter<Context, Record<never, never>>;
47
+ fromUser: typeof fromUser;
48
+ chatId: typeof chatId;
49
+ mediaType: typeof mediaType;
50
+ media: Filter<Context, Record<never, never>>;
51
+ hasEntity: typeof hasEntity;
52
+ and: typeof and;
53
+ or: typeof or;
54
+ not: typeof not;
55
+ };
56
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAiBpD,kCAAkC;AAClC,eAAO,MAAM,IAAI,EAAE,MAAM,CAAC,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAElD,CAAC;AAEF,iEAAiE;AACjE,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC,OAAO,EAAE;IAAE,KAAK,EAAE,gBAAgB,CAAA;CAAE,CAAC,CAQ9E;AAED,uFAAuF;AACvF,wBAAgB,OAAO,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,OAAO,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAa3F;AAED,oFAAoF;AACpF,wBAAgB,QAAQ,CAAC,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,CAE5D;AAED,eAAO,MAAM,SAAS,EAAE,MAAM,CAAC,OAAO,CAAuB,CAAC;AAC9D,eAAO,MAAM,OAAO,EAAE,MAAM,CAAC,OAAO,CAAmC,CAAC;AAExE,gDAAgD;AAChD,wBAAgB,QAAQ,CAAC,GAAG,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,CAE1D;AAED,8CAA8C;AAC9C,wBAAgB,MAAM,CAAC,GAAG,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,CAExD;AAaD,0EAA0E;AAC1E,wBAAgB,SAAS,CAAC,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,CAK7D;AAED,iCAAiC;AACjC,eAAO,MAAM,KAAK,EAAE,MAAM,CAAC,OAAO,CAGhC,CAAC;AAEH,qFAAqF;AACrF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,CAKvD;AAED,kEAAkE;AAClE,wBAAgB,GAAG,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,EACrD,CAAC,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,EACrB,CAAC,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,GACnB,MAAM,CAAC,OAAO,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;AAE1B,wBAAgB,GAAG,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,EACvE,CAAC,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,EACrB,CAAC,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,EACrB,CAAC,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,GACnB,MAAM,CAAC,OAAO,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;AAE9B,wBAAgB,GAAG,CAAC,GAAG,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;AAW5E,qFAAqF;AACrF,wBAAgB,EAAE,CAAC,GAAG,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,CAEzE;AAED,4DAA4D;AAC5D,wBAAgB,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,CAQpE;AA0BD,gGAAgG;AAChG,eAAO,MAAM,OAAO;;cAlJuB,MAAM;;;;;;;;;;;;;;;CAiKhD,CAAC"}
package/lib/index.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @yaebal/filters — composable, type-narrowing update filters (the mtcute idea),
3
+ * for the core `composer.filter(...)` method. a filter is a type guard that may
4
+ * also attach data to the context (e.g. `regex` exposes `ctx.match`); combine
5
+ * them with `and` / `or` / `not`.
6
+ *
7
+ * bot.filter(and(isPrivate, command("buy")), (ctx) => ctx.args);
8
+ * bot.filter(regex(/^\d+$/), (ctx) => ctx.match[0]);
9
+ * bot.filter(or(mediaType("photo"), mediaType("video")), handler);
10
+ */
11
+ const make = (test) => ({ test: test });
12
+ /** message has non-empty text. */
13
+ export const text = make((ctx) => typeof ctx.text === "string" && ctx.text.length > 0);
14
+ /** text matches `re`; exposes `ctx.match` (RegExpMatchArray). */
15
+ export function regex(re) {
16
+ return make((ctx) => {
17
+ const m = ctx.text?.match(re);
18
+ if (!m)
19
+ return false;
20
+ Object.assign(ctx, { match: m });
21
+ return true;
22
+ });
23
+ }
24
+ /** a `/command` (optionally a specific name); exposes `ctx.command` and `ctx.args`. */
25
+ export function command(name) {
26
+ return make((ctx) => {
27
+ const value = ctx.text;
28
+ if (!value || !value.startsWith("/"))
29
+ return false;
30
+ const parts = value.slice(1).split(/\s+/);
31
+ const head = parts[0]?.split("@")[0] ?? "";
32
+ if (name !== undefined && head !== name)
33
+ return false;
34
+ Object.assign(ctx, { command: head, args: parts.slice(1) });
35
+ return true;
36
+ });
37
+ }
38
+ /** chat is one of the given types (`private`, `group`, `supergroup`, `channel`). */
39
+ export function chatType(...types) {
40
+ return make((ctx) => types.includes(ctx.chat?.type ?? ""));
41
+ }
42
+ export const isPrivate = chatType("private");
43
+ export const isGroup = chatType("group", "supergroup");
44
+ /** update is from one of the given user ids. */
45
+ export function fromUser(...ids) {
46
+ return make((ctx) => ctx.from?.id !== undefined && ids.includes(ctx.from.id));
47
+ }
48
+ /** update is in one of the given chat ids. */
49
+ export function chatId(...ids) {
50
+ return make((ctx) => ctx.chat?.id !== undefined && ids.includes(ctx.chat.id));
51
+ }
52
+ const MEDIA_KINDS = [
53
+ "photo",
54
+ "video",
55
+ "document",
56
+ "audio",
57
+ "voice",
58
+ "sticker",
59
+ "animation",
60
+ "video_note",
61
+ ];
62
+ /** message carries one of the given media kinds (`photo`, `video`, …). */
63
+ export function mediaType(...kinds) {
64
+ return make((ctx) => {
65
+ const msg = ctx.message;
66
+ return !!msg && kinds.some((k) => msg[k] != null);
67
+ });
68
+ }
69
+ /** message carries any media. */
70
+ export const media = make((ctx) => {
71
+ const msg = ctx.message;
72
+ return !!msg && MEDIA_KINDS.some((k) => msg[k] != null);
73
+ });
74
+ /** message contains an entity of the given type (`url`, `mention`, `hashtag`, …). */
75
+ export function hasEntity(type) {
76
+ return make((ctx) => {
77
+ const entities = ctx.message?.entities;
78
+ return Array.isArray(entities) && entities.some((e) => e.type === type);
79
+ });
80
+ }
81
+ export function and(...filters) {
82
+ return make((ctx) => {
83
+ const before = ownKeys(ctx);
84
+ if (filters.every((f) => f.test(ctx)))
85
+ return true;
86
+ rollback(ctx, before); // a later filter failed → undo earlier filters' attachments
87
+ return false;
88
+ });
89
+ }
90
+ /** matches when any filter matches. no additions (the matched branch is unknown). */
91
+ export function or(...filters) {
92
+ return make((ctx) => filters.some((f) => attempt(f, ctx)));
93
+ }
94
+ /** matches when the filter does NOT match. no additions. */
95
+ export function not(filter) {
96
+ return make((ctx) => {
97
+ const before = ownKeys(ctx);
98
+ const matched = filter.test(ctx);
99
+ rollback(ctx, before); // the inner filter's attachments are never wanted by `not`
100
+ return !matched;
101
+ });
102
+ }
103
+ // data-attaching filters (regex/command/custom) call Object.assign before returning.
104
+ // when a combinator ends up rejecting, undo any fields the sub-filters attached so they
105
+ // don't leak onto the context for downstream middleware.
106
+ const ownKeys = (ctx) => new Set(Object.keys(ctx));
107
+ function rollback(ctx, before) {
108
+ const bag = ctx;
109
+ for (const key of Object.keys(bag)) {
110
+ if (!before.has(key)) {
111
+ delete bag[key];
112
+ }
113
+ }
114
+ }
115
+ function attempt(filter, ctx) {
116
+ const before = ownKeys(ctx);
117
+ if (filter.test(ctx))
118
+ return true;
119
+ rollback(ctx, before);
120
+ return false;
121
+ }
122
+ /** everything under one namespace, mtcute-style: `filters.command(...)`, `filters.and(...)`. */
123
+ export const filters = {
124
+ text,
125
+ regex,
126
+ command,
127
+ chatType,
128
+ isPrivate,
129
+ isGroup,
130
+ fromUser,
131
+ chatId,
132
+ mediaType,
133
+ media,
134
+ hasEntity,
135
+ and,
136
+ or,
137
+ not,
138
+ };
139
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,106 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { Composer, Context } from "@yaebal/core";
4
+ import { and, command, fromUser, isPrivate, not, or, regex, text } from "./index.js";
5
+ const noop = async () => { };
6
+ const entry = (c) => c.toMiddleware();
7
+ const api = {};
8
+ const msgCtx = (body, chatType = "private", fromId = 1) => new Context({
9
+ api,
10
+ update: {
11
+ update_id: 1,
12
+ message: {
13
+ message_id: 1,
14
+ date: 0,
15
+ chat: { id: 1, type: chatType },
16
+ from: { id: fromId, is_bot: false, first_name: "u" },
17
+ text: body,
18
+ },
19
+ },
20
+ updateType: "message",
21
+ });
22
+ const cbCtx = () => new Context({
23
+ api,
24
+ update: {
25
+ update_id: 2,
26
+ callback_query: { id: "q", from: { id: 1, is_bot: false, first_name: "u" }, data: "x" },
27
+ },
28
+ updateType: "callback_query",
29
+ });
30
+ test("filter(text) runs only on non-empty text", async () => {
31
+ const seen = [];
32
+ const mw = entry(new Composer().filter(text, (ctx) => seen.push(ctx.text)));
33
+ await mw(msgCtx("hi"), noop);
34
+ await mw(cbCtx(), noop); // no text
35
+ assert.deepEqual(seen, ["hi"]);
36
+ });
37
+ test("regex matches and exposes ctx.match", async () => {
38
+ let captured = "";
39
+ const mw = entry(new Composer().filter(regex(/^buy (\d+)/), (ctx) => {
40
+ captured = ctx.match[1] ?? "";
41
+ }));
42
+ await mw(msgCtx("buy 42"), noop);
43
+ assert.equal(captured, "42");
44
+ });
45
+ test("command matches a name and exposes ctx.command / ctx.args", async () => {
46
+ let cmd = "";
47
+ let args = [];
48
+ const mw = entry(new Composer().filter(command("add"), (ctx) => {
49
+ cmd = ctx.command;
50
+ args = ctx.args;
51
+ }));
52
+ await mw(msgCtx("/add a b"), noop);
53
+ assert.equal(cmd, "add");
54
+ assert.deepEqual(args, ["a", "b"]);
55
+ });
56
+ test("and requires all; or requires any; not inverts", async () => {
57
+ let andHits = 0;
58
+ const andMw = entry(new Composer().filter(and(isPrivate, command("buy")), () => {
59
+ andHits++;
60
+ }));
61
+ await andMw(msgCtx("/buy", "private"), noop); // ✓ private + command
62
+ await andMw(msgCtx("/buy", "group"), noop); // ✗ not private
63
+ assert.equal(andHits, 1);
64
+ let orHits = 0;
65
+ const orMw = entry(new Composer().filter(or(command("a"), command("b")), () => {
66
+ orHits++;
67
+ }));
68
+ await orMw(msgCtx("/a"), noop);
69
+ await orMw(msgCtx("/b"), noop);
70
+ await orMw(msgCtx("/c"), noop);
71
+ assert.equal(orHits, 2);
72
+ let notHits = 0;
73
+ const notMw = entry(new Composer().filter(not(fromUser(99)), () => {
74
+ notHits++;
75
+ }));
76
+ await notMw(msgCtx("hi", "private", 5), noop); // from 5 → allowed
77
+ await notMw(msgCtx("hi", "private", 99), noop); // from 99 → blocked
78
+ assert.equal(notHits, 1);
79
+ });
80
+ test("not() and failed and() do not leak attached data downstream", async () => {
81
+ const seen = [];
82
+ const probe = (ctx) => {
83
+ seen.push(ctx.command);
84
+ };
85
+ // not(command("a")) on "/a": command matches+attaches, not rejects → branch skipped,
86
+ // but ctx.command must NOT leak to the downstream use()
87
+ const notMw = entry(new Composer().filter(not(command("a")), () => { }).use(probe));
88
+ await notMw(msgCtx("/a"), noop);
89
+ // and(command("a"), fromUser(999)) on "/a" from user 1: command attaches, fromUser fails →
90
+ // the whole and rejects and must roll back command/args
91
+ const andMw = entry(new Composer().filter(and(command("a"), fromUser(999)), () => { }).use(probe));
92
+ await andMw(msgCtx("/a", "private", 1), noop);
93
+ assert.deepEqual(seen, [undefined, undefined]); // no leak in either case
94
+ });
95
+ test("scoped derive runs only for the listed update types", async () => {
96
+ const tags = [];
97
+ const mw = entry(new Composer()
98
+ .derive("message", () => ({ tag: 7 }))
99
+ .use((ctx) => {
100
+ tags.push(ctx.tag);
101
+ }));
102
+ await mw(msgCtx("hi"), noop); // message → derived
103
+ await mw(cbCtx(), noop); // callback_query → skipped
104
+ assert.deepEqual(tags, [7, undefined]);
105
+ });
106
+ //# sourceMappingURL=index.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@yaebal/filters",
3
+ "version": "0.0.1",
4
+ "description": "yaebal filters — composable, type-narrowing update filters (and/or/not) for ctx.filter().",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "import": "./lib/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "src"
17
+ ],
18
+ "dependencies": {
19
+ "@yaebal/core": "0.0.1"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "latest"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "keywords": [
28
+ "telegram",
29
+ "telegram-bot",
30
+ "yaebal",
31
+ "filter",
32
+ "filters"
33
+ ],
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/neverlane/yaebal",
38
+ "directory": "packages/filters"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.json",
45
+ "typecheck": "tsc -p tsconfig.json --noEmit",
46
+ "test": "node --test lib"
47
+ }
48
+ }
@@ -0,0 +1,150 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { Composer, Context, type Middleware } from "@yaebal/core";
4
+ import { and, command, fromUser, isPrivate, not, or, regex, text } from "./index.js";
5
+
6
+ const noop = async () => {};
7
+ const entry = <C extends Context>(c: Composer<C>) =>
8
+ c.toMiddleware() as unknown as Middleware<Context>;
9
+ const api = {} as never;
10
+
11
+ const msgCtx = (body: string, chatType = "private", fromId = 1) =>
12
+ new Context({
13
+ api,
14
+ update: {
15
+ update_id: 1,
16
+ message: {
17
+ message_id: 1,
18
+ date: 0,
19
+ chat: { id: 1, type: chatType },
20
+ from: { id: fromId, is_bot: false, first_name: "u" },
21
+ text: body,
22
+ },
23
+ } as never,
24
+ updateType: "message",
25
+ });
26
+
27
+ const cbCtx = () =>
28
+ new Context({
29
+ api,
30
+ update: {
31
+ update_id: 2,
32
+ callback_query: { id: "q", from: { id: 1, is_bot: false, first_name: "u" }, data: "x" },
33
+ } as never,
34
+ updateType: "callback_query",
35
+ });
36
+
37
+ test("filter(text) runs only on non-empty text", async () => {
38
+ const seen: string[] = [];
39
+
40
+ const mw = entry(new Composer<Context>().filter(text, (ctx) => seen.push(ctx.text)));
41
+ await mw(msgCtx("hi"), noop);
42
+ await mw(cbCtx(), noop); // no text
43
+
44
+ assert.deepEqual(seen, ["hi"]);
45
+ });
46
+
47
+ test("regex matches and exposes ctx.match", async () => {
48
+ let captured = "";
49
+
50
+ const mw = entry(
51
+ new Composer<Context>().filter(regex(/^buy (\d+)/), (ctx) => {
52
+ captured = ctx.match[1] ?? "";
53
+ }),
54
+ );
55
+ await mw(msgCtx("buy 42"), noop);
56
+
57
+ assert.equal(captured, "42");
58
+ });
59
+
60
+ test("command matches a name and exposes ctx.command / ctx.args", async () => {
61
+ let cmd = "";
62
+ let args: string[] = [];
63
+
64
+ const mw = entry(
65
+ new Composer<Context>().filter(command("add"), (ctx) => {
66
+ cmd = ctx.command;
67
+ args = ctx.args;
68
+ }),
69
+ );
70
+ await mw(msgCtx("/add a b"), noop);
71
+
72
+ assert.equal(cmd, "add");
73
+ assert.deepEqual(args, ["a", "b"]);
74
+ });
75
+
76
+ test("and requires all; or requires any; not inverts", async () => {
77
+ let andHits = 0;
78
+
79
+ const andMw = entry(
80
+ new Composer<Context>().filter(and(isPrivate, command("buy")), () => {
81
+ andHits++;
82
+ }),
83
+ );
84
+ await andMw(msgCtx("/buy", "private"), noop); // ✓ private + command
85
+ await andMw(msgCtx("/buy", "group"), noop); // ✗ not private
86
+
87
+ assert.equal(andHits, 1);
88
+
89
+ let orHits = 0;
90
+
91
+ const orMw = entry(
92
+ new Composer<Context>().filter(or(command("a"), command("b")), () => {
93
+ orHits++;
94
+ }),
95
+ );
96
+ await orMw(msgCtx("/a"), noop);
97
+ await orMw(msgCtx("/b"), noop);
98
+ await orMw(msgCtx("/c"), noop);
99
+
100
+ assert.equal(orHits, 2);
101
+
102
+ let notHits = 0;
103
+
104
+ const notMw = entry(
105
+ new Composer<Context>().filter(not(fromUser(99)), () => {
106
+ notHits++;
107
+ }),
108
+ );
109
+ await notMw(msgCtx("hi", "private", 5), noop); // from 5 → allowed
110
+ await notMw(msgCtx("hi", "private", 99), noop); // from 99 → blocked
111
+
112
+ assert.equal(notHits, 1);
113
+ });
114
+
115
+ test("not() and failed and() do not leak attached data downstream", async () => {
116
+ const seen: (string | undefined)[] = [];
117
+ const probe = (ctx: Context) => {
118
+ seen.push((ctx as Context & { command?: string }).command);
119
+ };
120
+
121
+ // not(command("a")) on "/a": command matches+attaches, not rejects → branch skipped,
122
+ // but ctx.command must NOT leak to the downstream use()
123
+ const notMw = entry(new Composer<Context>().filter(not(command("a")), () => {}).use(probe));
124
+ await notMw(msgCtx("/a"), noop);
125
+
126
+ // and(command("a"), fromUser(999)) on "/a" from user 1: command attaches, fromUser fails →
127
+ // the whole and rejects and must roll back command/args
128
+ const andMw = entry(
129
+ new Composer<Context>().filter(and(command("a"), fromUser(999)), () => {}).use(probe),
130
+ );
131
+ await andMw(msgCtx("/a", "private", 1), noop);
132
+
133
+ assert.deepEqual(seen, [undefined, undefined]); // no leak in either case
134
+ });
135
+
136
+ test("scoped derive runs only for the listed update types", async () => {
137
+ const tags: (number | undefined)[] = [];
138
+
139
+ const mw = entry(
140
+ new Composer<Context>()
141
+ .derive("message", () => ({ tag: 7 }))
142
+ .use((ctx) => {
143
+ tags.push((ctx as Context & { tag?: number }).tag);
144
+ }),
145
+ );
146
+ await mw(msgCtx("hi"), noop); // message → derived
147
+ await mw(cbCtx(), noop); // callback_query → skipped
148
+
149
+ assert.deepEqual(tags, [7, undefined]);
150
+ });
package/src/index.ts ADDED
@@ -0,0 +1,180 @@
1
+ import type { Context, Filter } from "@yaebal/core";
2
+
3
+ /**
4
+ * @yaebal/filters — composable, type-narrowing update filters (the mtcute idea),
5
+ * for the core `composer.filter(...)` method. a filter is a type guard that may
6
+ * also attach data to the context (e.g. `regex` exposes `ctx.match`); combine
7
+ * them with `and` / `or` / `not`.
8
+ *
9
+ * bot.filter(and(isPrivate, command("buy")), (ctx) => ctx.args);
10
+ * bot.filter(regex(/^\d+$/), (ctx) => ctx.match[0]);
11
+ * bot.filter(or(mediaType("photo"), mediaType("video")), handler);
12
+ */
13
+
14
+ const make = <Add extends object = Record<never, never>>(
15
+ test: (ctx: Context) => boolean,
16
+ ): Filter<Context, Add> => ({ test: test as (ctx: Context) => ctx is Context & Add });
17
+
18
+ /** message has non-empty text. */
19
+ export const text: Filter<Context, { text: string }> = make(
20
+ (ctx) => typeof ctx.text === "string" && ctx.text.length > 0,
21
+ );
22
+
23
+ /** text matches `re`; exposes `ctx.match` (RegExpMatchArray). */
24
+ export function regex(re: RegExp): Filter<Context, { match: RegExpMatchArray }> {
25
+ return make((ctx) => {
26
+ const m = ctx.text?.match(re);
27
+ if (!m) return false;
28
+
29
+ Object.assign(ctx as object, { match: m });
30
+ return true;
31
+ });
32
+ }
33
+
34
+ /** a `/command` (optionally a specific name); exposes `ctx.command` and `ctx.args`. */
35
+ export function command(name?: string): Filter<Context, { command: string; args: string[] }> {
36
+ return make((ctx) => {
37
+ const value = ctx.text;
38
+ if (!value || !value.startsWith("/")) return false;
39
+
40
+ const parts = value.slice(1).split(/\s+/);
41
+ const head = parts[0]?.split("@")[0] ?? "";
42
+
43
+ if (name !== undefined && head !== name) return false;
44
+ Object.assign(ctx as object, { command: head, args: parts.slice(1) });
45
+
46
+ return true;
47
+ });
48
+ }
49
+
50
+ /** chat is one of the given types (`private`, `group`, `supergroup`, `channel`). */
51
+ export function chatType(...types: string[]): Filter<Context> {
52
+ return make((ctx) => types.includes(ctx.chat?.type ?? ""));
53
+ }
54
+
55
+ export const isPrivate: Filter<Context> = chatType("private");
56
+ export const isGroup: Filter<Context> = chatType("group", "supergroup");
57
+
58
+ /** update is from one of the given user ids. */
59
+ export function fromUser(...ids: number[]): Filter<Context> {
60
+ return make((ctx) => ctx.from?.id !== undefined && ids.includes(ctx.from.id));
61
+ }
62
+
63
+ /** update is in one of the given chat ids. */
64
+ export function chatId(...ids: number[]): Filter<Context> {
65
+ return make((ctx) => ctx.chat?.id !== undefined && ids.includes(ctx.chat.id));
66
+ }
67
+
68
+ const MEDIA_KINDS = [
69
+ "photo",
70
+ "video",
71
+ "document",
72
+ "audio",
73
+ "voice",
74
+ "sticker",
75
+ "animation",
76
+ "video_note",
77
+ ] as const;
78
+
79
+ /** message carries one of the given media kinds (`photo`, `video`, …). */
80
+ export function mediaType(...kinds: string[]): Filter<Context> {
81
+ return make((ctx) => {
82
+ const msg = ctx.message as Record<string, unknown> | undefined;
83
+ return !!msg && kinds.some((k) => msg[k] != null);
84
+ });
85
+ }
86
+
87
+ /** message carries any media. */
88
+ export const media: Filter<Context> = make((ctx) => {
89
+ const msg = ctx.message as Record<string, unknown> | undefined;
90
+ return !!msg && MEDIA_KINDS.some((k) => msg[k] != null);
91
+ });
92
+
93
+ /** message contains an entity of the given type (`url`, `mention`, `hashtag`, …). */
94
+ export function hasEntity(type: string): Filter<Context> {
95
+ return make((ctx) => {
96
+ const entities = (ctx.message as { entities?: { type: string }[] } | undefined)?.entities;
97
+ return Array.isArray(entities) && entities.some((e) => e.type === type);
98
+ });
99
+ }
100
+
101
+ /** matches when every filter matches; the additions intersect. */
102
+ export function and<A extends object, B extends object>(
103
+ a: Filter<Context, A>,
104
+ b: Filter<Context, B>,
105
+ ): Filter<Context, A & B>;
106
+
107
+ export function and<A extends object, B extends object, D extends object>(
108
+ a: Filter<Context, A>,
109
+ b: Filter<Context, B>,
110
+ c: Filter<Context, D>,
111
+ ): Filter<Context, A & B & D>;
112
+
113
+ export function and(...filters: Filter<Context, object>[]): Filter<Context>;
114
+ export function and(...filters: Filter<Context, object>[]): Filter<Context> {
115
+ return make((ctx) => {
116
+ const before = ownKeys(ctx);
117
+ if (filters.every((f) => f.test(ctx))) return true;
118
+
119
+ rollback(ctx, before); // a later filter failed → undo earlier filters' attachments
120
+ return false;
121
+ });
122
+ }
123
+
124
+ /** matches when any filter matches. no additions (the matched branch is unknown). */
125
+ export function or(...filters: Filter<Context, object>[]): Filter<Context> {
126
+ return make((ctx) => filters.some((f) => attempt(f, ctx)));
127
+ }
128
+
129
+ /** matches when the filter does NOT match. no additions. */
130
+ export function not(filter: Filter<Context, object>): Filter<Context> {
131
+ return make((ctx) => {
132
+ const before = ownKeys(ctx);
133
+ const matched = filter.test(ctx);
134
+
135
+ rollback(ctx, before); // the inner filter's attachments are never wanted by `not`
136
+ return !matched;
137
+ });
138
+ }
139
+
140
+ // data-attaching filters (regex/command/custom) call Object.assign before returning.
141
+ // when a combinator ends up rejecting, undo any fields the sub-filters attached so they
142
+ // don't leak onto the context for downstream middleware.
143
+ const ownKeys = (ctx: Context): Set<string> => new Set(Object.keys(ctx));
144
+
145
+ function rollback(ctx: Context, before: Set<string>): void {
146
+ const bag = ctx as unknown as Record<string, unknown>;
147
+
148
+ for (const key of Object.keys(bag)) {
149
+ if (!before.has(key)) {
150
+ delete bag[key];
151
+ }
152
+ }
153
+ }
154
+
155
+ function attempt(filter: Filter<Context, object>, ctx: Context): boolean {
156
+ const before = ownKeys(ctx);
157
+
158
+ if (filter.test(ctx)) return true;
159
+
160
+ rollback(ctx, before);
161
+ return false;
162
+ }
163
+
164
+ /** everything under one namespace, mtcute-style: `filters.command(...)`, `filters.and(...)`. */
165
+ export const filters = {
166
+ text,
167
+ regex,
168
+ command,
169
+ chatType,
170
+ isPrivate,
171
+ isGroup,
172
+ fromUser,
173
+ chatId,
174
+ mediaType,
175
+ media,
176
+ hasEntity,
177
+ and,
178
+ or,
179
+ not,
180
+ };