@adriangalilea/utils 2.13.0 → 2.14.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/README.md +1 -0
- package/dist/bot/urls.d.ts +59 -0
- package/dist/bot/urls.d.ts.map +1 -0
- package/dist/bot/urls.js +73 -0
- package/dist/bot/urls.js.map +1 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -524,6 +524,7 @@ Then `pnpm install`. Every `ctx.send` / `ctx.sendDocument` / `ctx.reply` / etc.
|
|
|
524
524
|
| `@adriangalilea/utils/bot/groups` | Group-chat identity as plain functions: `chatIdOf(ctx)` (the chat id on any ctx flavour), `isGroupChat(ctx)` / `isPrivateChat(ctx)`, and `isGroupAdmin(ctx, { chatId?, userId? }?)` — the `getChatMember` creator/administrator check behind every admin-gated group setting (auto-summary toggles, "dismiss" buttons on group welcomes), chat/user defaulted from the ctx. Every reader resolves both gramio spellings — message ctxs carry `chat`, callback ctxs (inline-button taps) only `chatId`/`message.chat` — so the same gate works in handlers and taps; read chat ids via `chatIdOf`, never `ctx.chat?.id`. Takes real gramio ctxs and `MenuCtx` with no cast; a miswired ctx panics, an API rejection fails closed (`false`). Composition over policy: bot-owner override stays at your call site (`ctx.isAdmin \|\| await isGroupAdmin(ctx)`). |
|
|
525
525
|
| `@adriangalilea/utils/bot/language` (picker surface) | The language-picker vocabulary and surfaces, so bots don't fork label lists: `flagFor` / `autonym` / `languageLabel` ("🇪🇸 Español"), `languagePickerItem({ label, codes, isActive, pick })` (the 2-up primary-highlighted MenuItem factory — storage and policy live in your closures), and `addLanguageRows(kb, { codes, pack, active?, activeStyle? })` (the raw-InlineKeyboard twin for onboarding / group-welcome keyboards; `activeStyle: "success"` when primary already marks something else on the keyboard). |
|
|
526
526
|
| `@adriangalilea/utils/bot/user` | `userLabel(u)` — the conditional "[name] [@username] [id]" line every bot re-rolls for admin DMs and logs: `Ada Lovelace (@ada · 42)` / `Ada (42)` / `@ada (42)` / `id 42`, missing pieces drop instead of padding. Reads both gramio spellings (`firstName` / `first_name`); plain text by design. |
|
|
527
|
+
| `@adriangalilea/utils/bot/urls` | Telegram-message URL + entity parsing, on what the platform ALREADY parsed (entities carry exact UTF-16 spans — never `startsWith("/")` or a mention regex). `urlsInMessage(msg)` → the url module's `Url[]`: the visible text scanned by `urlsIn` PLUS `text_link` entities — hyperlinked words whose URL never appears in the text, invisible to any text scanner (what forwarded newsletters carry); a text_link's `raw` is its anchor words and its span covers them, so span-cutting treats linked words as the link. `isCommandMessage(msg)` (a `bot_command` entity at offset 0), `cutEntities(text, entities, shouldCut)` (exact span surgery; the predicate sees each visible slice), `messageTextAndEntities(msg)` (text or caption with ITS entity set). Framework-agnostic by shape: gramio's `MessageEntity` class and the raw Bot API object both fit. |
|
|
527
528
|
| `@adriangalilea/utils/bot/inline-feedback` | Behavioral tripwire for BotFather's inline feedback probability (gates `chosen_inline_result`; defaults 0%, silently resets on bot transfer and inline-mode toggling, invisible to `getMe`): served-but-never-chosen crosses a threshold → throttled admin DM naming the exact BotFather switch. Storage injected as three ops; never throws into the answer path. |
|
|
528
529
|
| `@adriangalilea/utils/bot/coalesce` | Joins client-split inbound messages back into one. When a user pastes >4096 chars, Telegram clients fragment it into separate `message` updates with no marker. Middleware detects the burst and emits one combined event. |
|
|
529
530
|
| `@adriangalilea/utils/llm` | Multi-provider LLM caller on the Vercel AI SDK — the policy layer the SDK doesn't ship. `createLlm({ providers, health? })` → `.stream(req)` (typed events: `delta` / `reasoning` / `reset` / `tool-call` / `end`) and `.complete(req)`. **Failover:** priority-ordered across OpenAI-compatible, Anthropic-compatible, and OpenRouter endpoints; multiple keys per provider (health tracked per key fingerprint); per-key circuit breaker with exponential cool-down persisted in any KV-shaped `HealthStore`; an attempt that dies after emitting yields `reset` (discard, next candidate regenerates); empty completions count as failures. **Accounting:** tokens summed across billed attempts; `costUsd` is the ACTUAL charge (OpenRouter usage accounting via `providerMetadata`, or a `cost` field on the raw usage frame) — never a price-table estimate. **Knobs:** per-model temperature, maxTokens caps, `disableThinking` mapped to each dialect. **Tools:** AI SDK `tool()` + `toolChoice` pass through (re-exported). Worker-safe; peers: `ai`, `@ai-sdk/openai-compatible`, `@ai-sdk/anthropic`, `@openrouter/ai-sdk-provider`. |
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram-message URL + entity parsing, on what the platform ALREADY parsed:
|
|
3
|
+
* every message arrives with `entities` (bot_command, mention, url, text_link,
|
|
4
|
+
* …) carrying exact UTF-16 spans — JS strings are UTF-16, so `slice(offset,
|
|
5
|
+
* offset + length)` is exact. Hand-sniffing `startsWith("/")` or regex-
|
|
6
|
+
* stripping `@botname` re-derives (worse) what Telegram handed over.
|
|
7
|
+
*
|
|
8
|
+
* The load-bearing verb is {@link urlsInMessage}: the visible text scanned by
|
|
9
|
+
* `urlsIn`, PLUS `text_link` entities — hyperlinked words whose URL never
|
|
10
|
+
* appears in the text, invisible to any text scanner (what forwarded
|
|
11
|
+
* newsletters and rich posts carry). Everything returns the same `Url`
|
|
12
|
+
* objects as the url module, spans indexing the message text (a text_link's
|
|
13
|
+
* span is its anchor words), so span-based cutting works uniformly.
|
|
14
|
+
*
|
|
15
|
+
* Framework-agnostic by shape: gramio's MessageEntity class satisfies
|
|
16
|
+
* {@link EntityLike} structurally (type/offset/length/url getters), as does
|
|
17
|
+
* the raw Bot API object.
|
|
18
|
+
*/
|
|
19
|
+
import { type Url, type UrlsInOptions } from "../universal/url/index.js";
|
|
20
|
+
/** The slice of a Telegram MessageEntity this module reads (raw API object or gramio class alike). */
|
|
21
|
+
export interface EntityLike {
|
|
22
|
+
type: string;
|
|
23
|
+
/** Offset in UTF-16 code units — JS string indexing, exactly. */
|
|
24
|
+
offset: number;
|
|
25
|
+
length: number;
|
|
26
|
+
/** For `text_link` only: the hidden URL behind the anchor words. */
|
|
27
|
+
url?: string;
|
|
28
|
+
}
|
|
29
|
+
/** The slice of a Telegram message this module reads: text or caption, with its entities. */
|
|
30
|
+
export interface MessageLike {
|
|
31
|
+
text?: string;
|
|
32
|
+
caption?: string;
|
|
33
|
+
entities?: readonly EntityLike[];
|
|
34
|
+
captionEntities?: readonly EntityLike[];
|
|
35
|
+
}
|
|
36
|
+
/** The message's text (or media caption), with the entity set that indexes it. */
|
|
37
|
+
export declare function messageTextAndEntities(message: MessageLike): {
|
|
38
|
+
text: string;
|
|
39
|
+
entities: readonly EntityLike[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Every URL the message carries, in span order, fully resolved: the visible
|
|
43
|
+
* text scanned by `urlsIn`, plus each `text_link` entity's hidden URL. A
|
|
44
|
+
* text_link's `raw` is its visible anchor words and its span covers them, so
|
|
45
|
+
* cutting link spans out of a message treats hyperlinked words as part of
|
|
46
|
+
* the link, not as the user's own words. text_link URLs always carry a
|
|
47
|
+
* scheme, so `requireScheme` never drops them.
|
|
48
|
+
*/
|
|
49
|
+
export declare function urlsInMessage(message: MessageLike, opts?: UrlsInOptions): Url[];
|
|
50
|
+
/** True when the message IS a command: a `bot_command` entity at offset 0 — Telegram's own parse, not `startsWith("/")`. */
|
|
51
|
+
export declare function isCommandMessage(message: MessageLike): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The message text with the entity spans `shouldCut` selects replaced by a
|
|
54
|
+
* space (offsets are honored exactly, so nested/adjacent formatting never
|
|
55
|
+
* shifts). The predicate receives each entity and its visible slice — e.g.
|
|
56
|
+
* cut `bot_command`s and mentions of your own bot, keep everything else.
|
|
57
|
+
*/
|
|
58
|
+
export declare function cutEntities(text: string, entities: readonly EntityLike[], shouldCut: (entity: EntityLike, slice: string) => boolean): string;
|
|
59
|
+
//# sourceMappingURL=urls.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"urls.d.ts","sourceRoot":"","sources":["../../src/bot/urls.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,KAAK,GAAG,EAAE,KAAK,aAAa,EAAiB,MAAM,2BAA2B,CAAC;AAExF,sGAAsG;AACtG,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAED,6FAA6F;AAC7F,MAAM,WAAW,WAAW;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;IACjC,eAAe,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;CACxC;AAED,kFAAkF;AAClF,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,WAAW,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAI9G;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,CAAC,EAAE,aAAa,GAAG,GAAG,EAAE,CAe/E;AAED,4HAA4H;AAC5H,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAG9D;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAC1B,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,SAAS,UAAU,EAAE,EAC/B,SAAS,EAAE,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,GACvD,MAAM,CASR"}
|
package/dist/bot/urls.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram-message URL + entity parsing, on what the platform ALREADY parsed:
|
|
3
|
+
* every message arrives with `entities` (bot_command, mention, url, text_link,
|
|
4
|
+
* …) carrying exact UTF-16 spans — JS strings are UTF-16, so `slice(offset,
|
|
5
|
+
* offset + length)` is exact. Hand-sniffing `startsWith("/")` or regex-
|
|
6
|
+
* stripping `@botname` re-derives (worse) what Telegram handed over.
|
|
7
|
+
*
|
|
8
|
+
* The load-bearing verb is {@link urlsInMessage}: the visible text scanned by
|
|
9
|
+
* `urlsIn`, PLUS `text_link` entities — hyperlinked words whose URL never
|
|
10
|
+
* appears in the text, invisible to any text scanner (what forwarded
|
|
11
|
+
* newsletters and rich posts carry). Everything returns the same `Url`
|
|
12
|
+
* objects as the url module, spans indexing the message text (a text_link's
|
|
13
|
+
* span is its anchor words), so span-based cutting works uniformly.
|
|
14
|
+
*
|
|
15
|
+
* Framework-agnostic by shape: gramio's MessageEntity class satisfies
|
|
16
|
+
* {@link EntityLike} structurally (type/offset/length/url getters), as does
|
|
17
|
+
* the raw Bot API object.
|
|
18
|
+
*/
|
|
19
|
+
import { urlOf, urlsIn } from "../universal/url/index.js";
|
|
20
|
+
/** The message's text (or media caption), with the entity set that indexes it. */
|
|
21
|
+
export function messageTextAndEntities(message) {
|
|
22
|
+
return message.text !== undefined
|
|
23
|
+
? { text: message.text, entities: message.entities ?? [] }
|
|
24
|
+
: { text: message.caption ?? "", entities: message.captionEntities ?? [] };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Every URL the message carries, in span order, fully resolved: the visible
|
|
28
|
+
* text scanned by `urlsIn`, plus each `text_link` entity's hidden URL. A
|
|
29
|
+
* text_link's `raw` is its visible anchor words and its span covers them, so
|
|
30
|
+
* cutting link spans out of a message treats hyperlinked words as part of
|
|
31
|
+
* the link, not as the user's own words. text_link URLs always carry a
|
|
32
|
+
* scheme, so `requireScheme` never drops them.
|
|
33
|
+
*/
|
|
34
|
+
export function urlsInMessage(message, opts) {
|
|
35
|
+
const { text, entities } = messageTextAndEntities(message);
|
|
36
|
+
const out = urlsIn(text, opts);
|
|
37
|
+
for (const entity of entities) {
|
|
38
|
+
if (entity.type !== "text_link" || !entity.url)
|
|
39
|
+
continue;
|
|
40
|
+
const resolved = urlOf(entity.url, opts);
|
|
41
|
+
if (!resolved)
|
|
42
|
+
continue;
|
|
43
|
+
out.push({
|
|
44
|
+
...resolved,
|
|
45
|
+
raw: text.slice(entity.offset, entity.offset + entity.length),
|
|
46
|
+
start: entity.offset,
|
|
47
|
+
end: entity.offset + entity.length,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return out.sort((a, b) => a.start - b.start);
|
|
51
|
+
}
|
|
52
|
+
/** True when the message IS a command: a `bot_command` entity at offset 0 — Telegram's own parse, not `startsWith("/")`. */
|
|
53
|
+
export function isCommandMessage(message) {
|
|
54
|
+
const { entities } = messageTextAndEntities(message);
|
|
55
|
+
return entities.some((e) => e.type === "bot_command" && e.offset === 0);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The message text with the entity spans `shouldCut` selects replaced by a
|
|
59
|
+
* space (offsets are honored exactly, so nested/adjacent formatting never
|
|
60
|
+
* shifts). The predicate receives each entity and its visible slice — e.g.
|
|
61
|
+
* cut `bot_command`s and mentions of your own bot, keep everything else.
|
|
62
|
+
*/
|
|
63
|
+
export function cutEntities(text, entities, shouldCut) {
|
|
64
|
+
const doomed = entities
|
|
65
|
+
.filter((e) => shouldCut(e, text.slice(e.offset, e.offset + e.length)))
|
|
66
|
+
.sort((a, b) => b.offset - a.offset);
|
|
67
|
+
let out = text;
|
|
68
|
+
for (const e of doomed) {
|
|
69
|
+
out = `${out.slice(0, e.offset)} ${out.slice(e.offset + e.length)}`;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=urls.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"urls.js","sourceRoot":"","sources":["../../src/bot/urls.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAgC,KAAK,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAC;AAoBxF,kFAAkF;AAClF,MAAM,UAAU,sBAAsB,CAAC,OAAoB;IAC1D,OAAO,OAAO,CAAC,IAAI,KAAK,SAAS;QAChC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,IAAI,EAAE,EAAE;QAC1D,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,OAAO,IAAI,EAAE,EAAE,QAAQ,EAAE,OAAO,CAAC,eAAe,IAAI,EAAE,EAAE,CAAC;AAC7E,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,OAAoB,EAAE,IAAoB;IACvE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,sBAAsB,CAAC,OAAO,CAAC,CAAC;IAC3D,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAC/B,KAAK,MAAM,MAAM,IAAI,QAAQ,EAAE,CAAC;QAC/B,IAAI,MAAM,CAAC,IAAI,KAAK,WAAW,IAAI,CAAC,MAAM,CAAC,GAAG;YAAE,SAAS;QACzD,MAAM,QAAQ,GAAG,KAAK,CAAC,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QACzC,IAAI,CAAC,QAAQ;YAAE,SAAS;QACxB,GAAG,CAAC,IAAI,CAAC;YACR,GAAG,QAAQ;YACX,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;YAC7D,KAAK,EAAE,MAAM,CAAC,MAAM;YACpB,GAAG,EAAE,MAAM,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM;SAClC,CAAC,CAAC;IACJ,CAAC;IACD,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;AAC9C,CAAC;AAED,4HAA4H;AAC5H,MAAM,UAAU,gBAAgB,CAAC,OAAoB;IACpD,MAAM,EAAE,QAAQ,EAAE,GAAG,sBAAsB,CAAC,OAAO,CAAC,CAAC;IACrD,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,aAAa,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC;AACzE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAC1B,IAAY,EACZ,QAA+B,EAC/B,SAAyD;IAEzD,MAAM,MAAM,GAAG,QAAQ;SACrB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,SAAS,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;SACtE,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC;IACtC,IAAI,GAAG,GAAG,IAAI,CAAC;IACf,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;QACxB,GAAG,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC;IACrE,CAAC;IACD,OAAO,GAAG,CAAC;AACZ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adriangalilea/utils",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "TypeScript utilities - logger, currency, formatter, GramIO bot plugins, and more",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -148,6 +148,10 @@
|
|
|
148
148
|
"types": "./dist/bot/user.d.ts",
|
|
149
149
|
"default": "./dist/bot/user.js"
|
|
150
150
|
},
|
|
151
|
+
"./bot/urls": {
|
|
152
|
+
"types": "./dist/bot/urls.d.ts",
|
|
153
|
+
"default": "./dist/bot/urls.js"
|
|
154
|
+
},
|
|
151
155
|
"./llm": {
|
|
152
156
|
"types": "./dist/llm/index.d.ts",
|
|
153
157
|
"default": "./dist/llm/index.js"
|
|
@@ -191,7 +195,8 @@
|
|
|
191
195
|
"test:llm": "tsx tests/llm-live-test.ts",
|
|
192
196
|
"test:tg-md": "tsx tests/tg-md-test.ts",
|
|
193
197
|
"test:self-echo": "tsx tests/self-echo-test.ts",
|
|
194
|
-
"test:url": "tsx tests/url-test.ts"
|
|
198
|
+
"test:url": "tsx tests/url-test.ts",
|
|
199
|
+
"test:bot-urls": "tsx tests/bot-urls-test.ts"
|
|
195
200
|
},
|
|
196
201
|
"keywords": [
|
|
197
202
|
"typescript",
|