@zhin.js/adapter-telegram 1.1.21 → 1.1.23

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 CHANGED
@@ -1,5 +1,70 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.23
4
+
5
+ ### Patch Changes
6
+
7
+ - bc6ccb1: Deep audit bug fixes and 7 architectural improvements:
8
+ - Sink Agent Turn ingress pipeline from `@zhin.js/cli` into `@zhin.js/agent/runtime` (`plugin-runtime/ingress/`) and decompose `ingress-route.ts` into modular pipeline stages.
9
+ - Bind Session State Plane (`JsxButtonRegistry`, `InteractionRuntime`, `RuntimeInteractiveRouter`) to `PluginIncarnationRef` so active buttons and interactions survive unrelated-plugin HMR while expiring when the owning plugin reloads, and support per-conversation fallback numbering (`numberingScope: 'conversation'`).
10
+ - Establish `conversation_events` as the canonical message SSOT and reference `conversation_event_id`, `conversation_key`, `platform_message_id`, and `message_key` in `agent_messages` (`AGENT_MESSAGE_QUERY_VERSION = 2`), threading `conversationSequence` across inbound and outbound delivery receipts.
11
+ - Push down `DatabaseContextRepository` and `DatabaseScheduleJobStorage` full-session/full-table memory scans to indexed SQL queries.
12
+ - Add kernel-level inbound `replyTo` and `mentioned` auto-promotion (`promoteInboundMessagePayload`) plus reusable `InboundDeduper` and `TtlLookupCache` primitives in `@zhin.js/adapter`.
13
+ - Add `MiddlewareContext.dispatch` (`InboundDispatchSummary`) and `onAfterDispatch` hooks for `after-dispatch` middlewares, and support incremental rendering of in-place `envelope.payload` array mutations in `OutboundDeliveryRuntime`.
14
+ - Unify `HistoryWindowTracker` and `AgentCompactionRuntime` token/message budgets (`resolveUnifiedHistoryAndCompactionBudget`).
15
+ - Fix schema validation, database transaction connection cleanup and SQLite WAL mode, schedule timer wheel head-of-line blocking, security policy bash read paths and subagent guard propagation, group merge window permission carrier handoff, micro-compact namespaced tool matching, and WebSocket force-close / inbound segment mapping across adapters.
16
+ - Updated dependencies [3a8504b]
17
+ - Updated dependencies [6163abc]
18
+ - Updated dependencies [fed2c27]
19
+ - Updated dependencies [bc6ccb1]
20
+ - Updated dependencies [72b5a12]
21
+ - Updated dependencies [aa41889]
22
+ - Updated dependencies [4427e72]
23
+ - Updated dependencies [22bd14a]
24
+ - @zhin.js/agent-feature@1.1.14
25
+ - @zhin.js/core@1.1.57
26
+ - @zhin.js/client@1.1.25
27
+ - @zhin.js/page@1.1.15
28
+ - @zhin.js/im-contract@1.1.12
29
+ - @zhin.js/adapter@1.1.29
30
+ - @zhin.js/host-http@1.1.15
31
+ - @zhin.js/feature-kit@1.1.13
32
+ - @zhin.js/tool@1.1.16
33
+ - @zhin.js/skill@1.1.14
34
+
35
+ ## 1.1.22
36
+
37
+ ### Patch Changes
38
+
39
+ - 41ff1a5: ADR 0044 C5: every official adapter now declares a capability `profile` (its `segments` moved into `profile.default.messaging.segments`). icqq declares the complete profile (per conversation kind: scene, all 14 actions with real limits, events, feedback, delivery) and implements the seven new management ports (`pokeUser`, `unmuteGroupMember`, `muteAllGroupMembers`, `setGroupCard`, `setGroupTitle`, `setGroupEssence`, `inviteGroupMember`) with fresh bot-role reads. qq official declares its passive reply windows; undeclared capabilities stay unsupported.
40
+ - cbd3f14: ADR 0044 integration and C6 guard. `delivery.spool` now reproduces the pre-0044 media rule (icqq `false`, every other official adapter `true`). icqq narrows `actions.recall.others` unless the Bot is admin or owner, and declares recall for groups. Endpoint kind capabilities merge the Endpoint's `operations` (Discord reactions keep working; Discord declares reaction feedback). Adapters in the `@zhin.js/` scope are trusted: their custom prompt text is not labelled `external_content`. Core no longer keeps a telegram/discord interactive-policy table (adapters declare `segments.interactive`). New `pnpm check:no-platform-branches` blocks platform-name branches in runtime code.
41
+ - 4fe49b5: 支持配置按需生效:分离保存与有效版本,独立变更先应用,账号资料更新保留连接,模型按回合使用确定版本;Console 返回结构化应用结果。监听改为仅显式 --watch 启用,开发模板显式选择源码和配置监听。
42
+
43
+ 自变更消费配置应用回执,避免已处理配置被二次重载;持久化本次写入修订和应用结果,未知结果或修订冲突时停止自动续作并保留现场,恢复不会覆盖未经本次变更确认的配置。
44
+
45
+ - Updated dependencies [b249e5e]
46
+ - Updated dependencies [57661f6]
47
+ - Updated dependencies [cbd3f14]
48
+ - Updated dependencies [299863a]
49
+ - Updated dependencies [aaa93d7]
50
+ - Updated dependencies [bd524d1]
51
+ - Updated dependencies [126ae99]
52
+ - Updated dependencies [4fe49b5]
53
+ - Updated dependencies [cce07db]
54
+ - Updated dependencies [b652db3]
55
+ - Updated dependencies [0315ba0]
56
+ - Updated dependencies [00be764]
57
+ - @zhin.js/core@1.1.56
58
+ - @zhin.js/adapter@1.1.28
59
+ - @zhin.js/im-contract@1.1.11
60
+ - @zhin.js/tool@1.1.15
61
+ - @zhin.js/skill@1.1.13
62
+ - @zhin.js/host-http@1.1.14
63
+ - @zhin.js/feature-kit@1.1.12
64
+ - @zhin.js/client@1.1.24
65
+ - @zhin.js/page@1.1.14
66
+ - @zhin.js/agent-feature@1.1.13
67
+
3
68
  ## 1.1.21
4
69
 
5
70
  ### Patch Changes
@@ -8,13 +8,28 @@ import {} from "../../lib/protocol.js";
8
8
  export { TelegramEndpoint } from "../../lib/endpoint.js";
9
9
  export default defineAdapter({
10
10
  capabilities: ['inbound', 'outbound'],
11
- operations: ['recall'],
12
11
  // 媒体 url / file_id 直发,base64 / 本地路径经 multipart attach:// 上传物化;
13
12
  // inline keyboard 原生按钮承载交互段。
14
- segments: {
15
- outboundMedia: ['url', 'upload'],
16
- interactive: 'native',
17
- markdown: 'native',
13
+ profile: {
14
+ default: {
15
+ messaging: {
16
+ segments: {
17
+ outboundMedia: ['url', 'upload'],
18
+ interactive: 'native',
19
+ markdown: 'native',
20
+ },
21
+ quote: true,
22
+ recall: { timeLimitSeconds: 172800 },
23
+ },
24
+ actions: {
25
+ recall: {
26
+ scopes: ['private', 'group', 'channel'],
27
+ params: { timeLimitSeconds: 172800 },
28
+ },
29
+ quoteReply: { scopes: ['private', 'group', 'channel'] },
30
+ },
31
+ delivery: { send: true, proactive: true, spool: true },
32
+ },
18
33
  },
19
34
  createAdapter: context => new TelegramAdapter(context),
20
35
  });
@@ -12,13 +12,28 @@ export type { TelegramEndpointOptions, TelegramFetch } from '../../src/endpoint.
12
12
 
13
13
  export default defineAdapter<TelegramEndpointConfig>({
14
14
  capabilities: ['inbound', 'outbound'],
15
- operations: ['recall'],
16
15
  // 媒体 url / file_id 直发,base64 / 本地路径经 multipart attach:// 上传物化;
17
16
  // inline keyboard 原生按钮承载交互段。
18
- segments: {
19
- outboundMedia: ['url', 'upload'],
20
- interactive: 'native',
21
- markdown: 'native',
17
+ profile: {
18
+ default: {
19
+ messaging: {
20
+ segments: {
21
+ outboundMedia: ['url', 'upload'],
22
+ interactive: 'native',
23
+ markdown: 'native',
24
+ },
25
+ quote: true,
26
+ recall: { timeLimitSeconds: 172800 },
27
+ },
28
+ actions: {
29
+ recall: {
30
+ scopes: ['private', 'group', 'channel'],
31
+ params: { timeLimitSeconds: 172800 },
32
+ },
33
+ quoteReply: { scopes: ['private', 'group', 'channel'] },
34
+ },
35
+ delivery: { send: true, proactive: true, spool: true },
36
+ },
22
37
  },
23
38
  createAdapter: context => new TelegramAdapter(context),
24
39
  });
package/lib/endpoint.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { TelegramClient } from './telegram-client.js';
2
2
  import { createTelegramStreaming } from './streaming.js';
3
- import { createEndpointSpecification, endpointSpec, createEndpointLifecycle, } from 'zhin.js/adapter';
3
+ import { createEndpointSpecification, endpointSpec, createEndpointLifecycle, InboundDeduper, TtlLookupCache, } from 'zhin.js/adapter';
4
4
  /**
5
5
  * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
6
6
  */
@@ -35,12 +35,15 @@ export class TelegramEndpoint extends TelegramClient {
35
35
  #executionSignal;
36
36
  #updateOffset = 0;
37
37
  #pollingHealthy = true;
38
- #admittedUpdates = new Set();
38
+ #admittedUpdates = new InboundDeduper({ maxEntries: ADMITTED_UPDATE_LIMIT });
39
39
  #inflightUpdates = new Map();
40
40
  #botUserId;
41
41
  #botUsername;
42
42
  #botName;
43
- #chatMemberCache = new Map();
43
+ #chatMemberCache = new TtlLookupCache({
44
+ ttlMs: CHAT_MEMBER_CACHE_TTL_MS,
45
+ maxEntries: CHAT_MEMBER_CACHE_MAX,
46
+ });
44
47
  #control = Object.freeze({
45
48
  recall: ({ message, signal }) => this.recallMessage(message, signal),
46
49
  });
@@ -341,6 +344,7 @@ export class TelegramEndpoint extends TelegramClient {
341
344
  },
342
345
  endpointId: this.#options.config.id,
343
346
  ...(mentioned ? { mentioned: true } : {}),
347
+ ...(msg.reply_to_message?.message_id != null ? { replyTo: { id: String(msg.reply_to_message.message_id) } } : {}),
344
348
  metadata: Object.freeze({
345
349
  channelType: resolveTelegramChannelType(msg.chat.type),
346
350
  chatType: msg.chat.type,
@@ -351,21 +355,27 @@ export class TelegramEndpoint extends TelegramClient {
351
355
  }),
352
356
  });
353
357
  }
354
- /** entities 里 mention 文本命中 bot username(getMe 缓存),或 text_mention 指向 bot 用户。 */
358
+ /** entities / caption_entities 里 mention 文本命中 bot username(getMe 缓存),或 text_mention 指向 bot 用户。 */
355
359
  #isBotMentioned(msg) {
356
- if (!msg.entities?.length)
357
- return false;
358
- for (const entity of msg.entities) {
359
- if (entity.type === 'text_mention') {
360
- if (this.#botUserId != null && entity.user?.id === this.#botUserId)
361
- return true;
360
+ const sources = [
361
+ { text: msg.text ?? '', entities: msg.entities },
362
+ { text: msg.caption ?? '', entities: msg.caption_entities },
363
+ ];
364
+ for (const { text, entities } of sources) {
365
+ if (!entities?.length)
362
366
  continue;
367
+ for (const entity of entities) {
368
+ if (entity.type === 'text_mention') {
369
+ if (this.#botUserId != null && entity.user?.id === this.#botUserId)
370
+ return true;
371
+ continue;
372
+ }
373
+ if (entity.type !== 'mention' || !this.#botUsername)
374
+ continue;
375
+ const slice = text.slice(entity.offset, entity.offset + entity.length);
376
+ if (slice.toLowerCase() === `@${this.#botUsername.toLowerCase()}`)
377
+ return true;
363
378
  }
364
- if (entity.type !== 'mention' || !this.#botUsername)
365
- continue;
366
- const slice = (msg.text ?? '').slice(entity.offset, entity.offset + entity.length);
367
- if (slice.toLowerCase() === `@${this.#botUsername.toLowerCase()}`)
368
- return true;
369
379
  }
370
380
  return false;
371
381
  }
@@ -376,10 +386,8 @@ export class TelegramEndpoint extends TelegramClient {
376
386
  const chatId = Number(msg.chat.id);
377
387
  const userId = msg.from.id;
378
388
  const key = `${chatId}:${userId}`;
379
- const now = Date.now();
380
- this.#sweepChatMemberCache(now);
381
389
  const cached = this.#chatMemberCache.get(key);
382
- if (cached && now - cached.at < CHAT_MEMBER_CACHE_TTL_MS)
390
+ if (cached)
383
391
  return cached;
384
392
  try {
385
393
  const member = await this.callApi('getChatMember', {
@@ -389,7 +397,7 @@ export class TelegramEndpoint extends TelegramClient {
389
397
  if (signal.aborted)
390
398
  return undefined;
391
399
  const normalized = normalizeTelegramChatMember(member);
392
- const entry = { at: now, ...normalized };
400
+ const entry = { ...normalized };
393
401
  this.#chatMemberCache.set(key, entry);
394
402
  return entry;
395
403
  }
@@ -398,22 +406,6 @@ export class TelegramEndpoint extends TelegramClient {
398
406
  return undefined;
399
407
  }
400
408
  }
401
- #sweepChatMemberCache(now) {
402
- for (const [key, entry] of this.#chatMemberCache) {
403
- if (now - entry.at >= CHAT_MEMBER_CACHE_TTL_MS)
404
- this.#chatMemberCache.delete(key);
405
- }
406
- if (this.#chatMemberCache.size > CHAT_MEMBER_CACHE_MAX) {
407
- const excess = this.#chatMemberCache.size - CHAT_MEMBER_CACHE_MAX;
408
- let removed = 0;
409
- for (const [key] of this.#chatMemberCache) {
410
- if (removed >= excess)
411
- break;
412
- this.#chatMemberCache.delete(key);
413
- removed++;
414
- }
415
- }
416
- }
417
409
  /** Test / internal: admit a callback query when open. */
418
410
  admitCallback(query) {
419
411
  void this[endpointSpec].events.run(() => {
@@ -455,7 +447,8 @@ export class TelegramEndpoint extends TelegramClient {
455
447
  if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
456
448
  throw new TypeError('Telegram update_id must be a non-negative safe integer');
457
449
  }
458
- if (this.#admittedUpdates.has(update.update_id))
450
+ const updateKey = String(update.update_id);
451
+ if (this.#admittedUpdates.has(updateKey))
459
452
  return;
460
453
  const pending = this.#inflightUpdates.get(update.update_id);
461
454
  if (pending)
@@ -468,10 +461,7 @@ export class TelegramEndpoint extends TelegramClient {
468
461
  // Once canonical dispatch enters Core, its generation lease drains the work.
469
462
  // Closing the transport cannot turn successful business processing into a retry.
470
463
  await this.#receiveUpdate(update, AbortSignal.any([admission.signal, this.#executionSignal]));
471
- this.#admittedUpdates.add(update.update_id);
472
- if (this.#admittedUpdates.size > ADMITTED_UPDATE_LIMIT) {
473
- this.#admittedUpdates.delete(this.#admittedUpdates.values().next().value);
474
- }
464
+ this.#admittedUpdates.shouldProcess(updateKey);
475
465
  })();
476
466
  this.#inflightUpdates.set(update.update_id, receiving);
477
467
  try {
package/lib/protocol.d.ts CHANGED
@@ -73,6 +73,7 @@ export interface TelegramMessage {
73
73
  readonly text?: string;
74
74
  readonly caption?: string;
75
75
  readonly entities?: readonly TelegramMessageEntity[];
76
+ readonly caption_entities?: readonly TelegramMessageEntity[];
76
77
  readonly reply_to_message?: TelegramMessage;
77
78
  readonly photo?: readonly TelegramPhotoSize[];
78
79
  readonly video?: {
@@ -145,6 +146,7 @@ export interface TelegramWireSegment {
145
146
  export interface TelegramInlineButton {
146
147
  readonly text: string;
147
148
  readonly callback_data?: string;
149
+ readonly url?: string;
148
150
  /** Bot API 10.3+: mutually exclusive with callback_data. */
149
151
  readonly disabled?: Record<string, never>;
150
152
  }
package/lib/protocol.js CHANGED
@@ -349,12 +349,15 @@ function buildOutboundActions(target, payload, uploads) {
349
349
  : {};
350
350
  const text = String(record.label ?? record.text ?? '');
351
351
  const callback = String(record.payload ?? record.callback_data ?? '');
352
+ const url = typeof record.url === 'string' && record.url.trim() ? record.url.trim() : undefined;
352
353
  if (record.mode === 'command')
353
354
  throw new EndpointDeliveryError('unsupported_operation', 'Telegram command buttons are not implemented', 'not_sent');
354
355
  if (!text.trim())
355
356
  throw new EndpointDeliveryError('invalid_payload', 'Telegram button label must be nonempty', 'not_sent');
356
357
  if (record.disabled)
357
358
  return { text, disabled: {} };
359
+ if (url && /^https?:\/\//i.test(url))
360
+ return { text, url };
358
361
  if (Buffer.byteLength(callback, 'utf8') < 1 || Buffer.byteLength(callback, 'utf8') > 64)
359
362
  throw new EndpointDeliveryError('invalid_payload', 'Telegram callback payload must be 1-64 UTF-8 bytes and label nonempty', 'not_sent');
360
363
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-telegram",
3
- "version": "1.1.21",
3
+ "version": "1.1.23",
4
4
  "description": "Zhin.js Telegram Bot API adapter for Plugin Runtime (long-poll getUpdates)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -31,26 +31,26 @@
31
31
  "directory": "plugins/adapters/telegram"
32
32
  },
33
33
  "dependencies": {
34
- "@zhin.js/adapter": "1.1.27",
35
- "@zhin.js/agent-feature": "1.1.12",
36
- "@zhin.js/client": "1.1.23",
34
+ "@zhin.js/adapter": "1.1.29",
35
+ "@zhin.js/agent-feature": "1.1.14",
36
+ "@zhin.js/client": "1.1.25",
37
37
  "@zhin.js/console-contract": "1.1.3",
38
- "@zhin.js/core": "1.1.55",
39
- "@zhin.js/feature-kit": "1.1.11",
40
- "@zhin.js/host-http": "1.1.13",
41
- "@zhin.js/im-contract": "1.1.10",
38
+ "@zhin.js/core": "1.1.57",
39
+ "@zhin.js/feature-kit": "1.1.13",
40
+ "@zhin.js/host-http": "1.1.15",
41
+ "@zhin.js/im-contract": "1.1.12",
42
42
  "@zhin.js/logger": "1.1.2",
43
- "@zhin.js/page": "1.1.13",
44
- "@zhin.js/skill": "1.1.12",
45
- "@zhin.js/tool": "1.1.14"
43
+ "@zhin.js/page": "1.1.15",
44
+ "@zhin.js/skill": "1.1.14",
45
+ "@zhin.js/tool": "1.1.16"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "zod": "^4.0.0",
49
- "@zhin.js/adapter": "^1.1.27",
50
- "@zhin.js/command": "^1.1.13",
51
- "@zhin.js/core": "^1.1.55",
52
- "@zhin.js/permission": "^1.1.11",
53
- "zhin.js": "^1.1.20"
49
+ "@zhin.js/adapter": "^1.1.29",
50
+ "@zhin.js/command": "^1.1.15",
51
+ "@zhin.js/core": "^1.1.57",
52
+ "@zhin.js/permission": "^1.1.13",
53
+ "zhin.js": "^1.1.22"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "@zhin.js/command": {
@@ -69,9 +69,9 @@
69
69
  "typescript": "^6.0.3",
70
70
  "vitest": "^4.1.10",
71
71
  "zod": "^4.4.3",
72
- "@zhin.js/host-http": "1.1.13",
73
- "@zhin.js/pagemanager": "1.1.20",
74
- "zhin.js": "1.1.20"
72
+ "@zhin.js/host-http": "1.1.15",
73
+ "@zhin.js/pagemanager": "1.1.22",
74
+ "zhin.js": "1.1.22"
75
75
  },
76
76
  "files": [
77
77
  "adapters",
package/plugin.js CHANGED
@@ -5,6 +5,7 @@ import { checkTelegramPlatformPermit } from "./lib/platform-permit.js";
5
5
  import { createTelegramConsoleRpc } from "./lib/console.js";
6
6
  export default definePlugin({
7
7
  name: 'telegram',
8
+ configUpdate: { featurePaths: [['nickname'], ['profile'], ['endpoints']] },
8
9
  metadata: {
9
10
  displayName: 'Telegram Bot API Adapter',
10
11
  },
package/src/endpoint.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  createEndpointSpecification, endpointSpec, type Endpoint, type EndpointSpecification, createEndpointLifecycle,
5
5
  type EndpointLifecycle, type EndpointContentPort, type EndpointContentResolveContext,
6
6
  type EndpointControl, type EndpointSendRequest, type EndpointTransportState,
7
+ InboundDeduper, TtlLookupCache,
7
8
  } from 'zhin.js/adapter';
8
9
  /**
9
10
  * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
@@ -49,7 +50,6 @@ const ADMITTED_UPDATE_LIMIT = 2_048;
49
50
  const INFLIGHT_UPDATE_LIMIT = 256;
50
51
 
51
52
  interface ChatMemberPermit {
52
- readonly at: number;
53
53
  readonly role?: string;
54
54
  readonly permissions: string[];
55
55
  }
@@ -91,12 +91,15 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
91
91
  #executionSignal?: AbortSignal;
92
92
  #updateOffset = 0;
93
93
  #pollingHealthy = true;
94
- readonly #admittedUpdates = new Set<number>();
94
+ readonly #admittedUpdates = new InboundDeduper({ maxEntries: ADMITTED_UPDATE_LIMIT });
95
95
  readonly #inflightUpdates = new Map<number, Promise<void>>();
96
96
  #botUserId?: number;
97
97
  #botUsername?: string;
98
98
  #botName?: string;
99
- readonly #chatMemberCache = new Map<string, ChatMemberPermit>();
99
+ readonly #chatMemberCache = new TtlLookupCache<string, ChatMemberPermit>({
100
+ ttlMs: CHAT_MEMBER_CACHE_TTL_MS,
101
+ maxEntries: CHAT_MEMBER_CACHE_MAX,
102
+ });
100
103
  readonly #control: EndpointControl = Object.freeze<EndpointControl>({
101
104
  recall: ({ message, signal }) => this.recallMessage(message, signal),
102
105
  });
@@ -410,6 +413,7 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
410
413
  },
411
414
  endpointId: this.#options.config.id,
412
415
  ...(mentioned ? { mentioned: true } : {}),
416
+ ...(msg.reply_to_message?.message_id != null ? { replyTo: { id: String(msg.reply_to_message.message_id) } } : {}),
413
417
  metadata: Object.freeze({
414
418
  channelType: resolveTelegramChannelType(msg.chat.type),
415
419
  chatType: msg.chat.type,
@@ -421,17 +425,23 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
421
425
  });
422
426
  }
423
427
 
424
- /** entities 里 mention 文本命中 bot username(getMe 缓存),或 text_mention 指向 bot 用户。 */
428
+ /** entities / caption_entities 里 mention 文本命中 bot username(getMe 缓存),或 text_mention 指向 bot 用户。 */
425
429
  #isBotMentioned(msg: TelegramMessage): boolean {
426
- if (!msg.entities?.length) return false;
427
- for (const entity of msg.entities) {
428
- if (entity.type === 'text_mention') {
429
- if (this.#botUserId != null && entity.user?.id === this.#botUserId) return true;
430
- continue;
430
+ const sources: Array<{ readonly text: string; readonly entities: readonly import('./protocol.js').TelegramMessageEntity[] | undefined }> = [
431
+ { text: msg.text ?? '', entities: msg.entities },
432
+ { text: msg.caption ?? '', entities: msg.caption_entities },
433
+ ];
434
+ for (const { text, entities } of sources) {
435
+ if (!entities?.length) continue;
436
+ for (const entity of entities) {
437
+ if (entity.type === 'text_mention') {
438
+ if (this.#botUserId != null && entity.user?.id === this.#botUserId) return true;
439
+ continue;
440
+ }
441
+ if (entity.type !== 'mention' || !this.#botUsername) continue;
442
+ const slice = text.slice(entity.offset, entity.offset + entity.length);
443
+ if (slice.toLowerCase() === `@${this.#botUsername.toLowerCase()}`) return true;
431
444
  }
432
- if (entity.type !== 'mention' || !this.#botUsername) continue;
433
- const slice = (msg.text ?? '').slice(entity.offset, entity.offset + entity.length);
434
- if (slice.toLowerCase() === `@${this.#botUsername.toLowerCase()}`) return true;
435
445
  }
436
446
  return false;
437
447
  }
@@ -442,10 +452,8 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
442
452
  const chatId = Number(msg.chat.id);
443
453
  const userId = msg.from.id;
444
454
  const key = `${chatId}:${userId}`;
445
- const now = Date.now();
446
- this.#sweepChatMemberCache(now);
447
455
  const cached = this.#chatMemberCache.get(key);
448
- if (cached && now - cached.at < CHAT_MEMBER_CACHE_TTL_MS) return cached;
456
+ if (cached) return cached;
449
457
  try {
450
458
  const member = await this.callApi<TelegramChatMember>('getChatMember', {
451
459
  chat_id: chatId,
@@ -453,7 +461,7 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
453
461
  }, signal);
454
462
  if (signal.aborted) return undefined;
455
463
  const normalized = normalizeTelegramChatMember(member);
456
- const entry: ChatMemberPermit = { at: now, ...normalized };
464
+ const entry: ChatMemberPermit = { ...normalized };
457
465
  this.#chatMemberCache.set(key, entry);
458
466
  return entry;
459
467
  } catch {
@@ -462,21 +470,6 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
462
470
  }
463
471
  }
464
472
 
465
- #sweepChatMemberCache(now: number): void {
466
- for (const [key, entry] of this.#chatMemberCache) {
467
- if (now - entry.at >= CHAT_MEMBER_CACHE_TTL_MS) this.#chatMemberCache.delete(key);
468
- }
469
- if (this.#chatMemberCache.size > CHAT_MEMBER_CACHE_MAX) {
470
- const excess = this.#chatMemberCache.size - CHAT_MEMBER_CACHE_MAX;
471
- let removed = 0;
472
- for (const [key] of this.#chatMemberCache) {
473
- if (removed >= excess) break;
474
- this.#chatMemberCache.delete(key);
475
- removed++;
476
- }
477
- }
478
- }
479
-
480
473
  /** Test / internal: admit a callback query when open. */
481
474
  admitCallback(query: TelegramCallbackQuery): void {
482
475
  void this[endpointSpec].events.run(() => {
@@ -519,7 +512,8 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
519
512
  if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
520
513
  throw new TypeError('Telegram update_id must be a non-negative safe integer');
521
514
  }
522
- if (this.#admittedUpdates.has(update.update_id)) return;
515
+ const updateKey = String(update.update_id);
516
+ if (this.#admittedUpdates.has(updateKey)) return;
523
517
  const pending = this.#inflightUpdates.get(update.update_id);
524
518
  if (pending) return pending;
525
519
  if (this.#inflightUpdates.size >= INFLIGHT_UPDATE_LIMIT) {
@@ -530,10 +524,7 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
530
524
  // Once canonical dispatch enters Core, its generation lease drains the work.
531
525
  // Closing the transport cannot turn successful business processing into a retry.
532
526
  await this.#receiveUpdate(update, AbortSignal.any([admission.signal, this.#executionSignal!]));
533
- this.#admittedUpdates.add(update.update_id);
534
- if (this.#admittedUpdates.size > ADMITTED_UPDATE_LIMIT) {
535
- this.#admittedUpdates.delete(this.#admittedUpdates.values().next().value!);
536
- }
527
+ this.#admittedUpdates.shouldProcess(updateKey);
537
528
  })();
538
529
  this.#inflightUpdates.set(update.update_id, receiving);
539
530
  try {
package/src/protocol.ts CHANGED
@@ -85,6 +85,7 @@ export interface TelegramMessage {
85
85
  readonly text?: string;
86
86
  readonly caption?: string;
87
87
  readonly entities?: readonly TelegramMessageEntity[];
88
+ readonly caption_entities?: readonly TelegramMessageEntity[];
88
89
  readonly reply_to_message?: TelegramMessage;
89
90
  readonly photo?: readonly TelegramPhotoSize[];
90
91
  readonly video?: {
@@ -162,6 +163,7 @@ export interface TelegramWireSegment {
162
163
  export interface TelegramInlineButton {
163
164
  readonly text: string;
164
165
  readonly callback_data?: string;
166
+ readonly url?: string;
165
167
  /** Bot API 10.3+: mutually exclusive with callback_data. */
166
168
  readonly disabled?: Record<string, never>;
167
169
  }
@@ -625,13 +627,15 @@ function buildOutboundActions(
625
627
  const buttons = Array.isArray(row) ? row : [];
626
628
  return buttons.map((btn) => {
627
629
  const record = btn && typeof btn === 'object'
628
- ? btn as { label?: string; text?: string; payload?: string; callback_data?: string; disabled?: boolean; mode?: string }
630
+ ? btn as { label?: string; text?: string; payload?: string; callback_data?: string; url?: string; disabled?: boolean; mode?: string }
629
631
  : {};
630
632
  const text = String(record.label ?? record.text ?? '');
631
633
  const callback = String(record.payload ?? record.callback_data ?? '');
634
+ const url = typeof record.url === 'string' && record.url.trim() ? record.url.trim() : undefined;
632
635
  if (record.mode === 'command') throw new EndpointDeliveryError('unsupported_operation', 'Telegram command buttons are not implemented', 'not_sent');
633
636
  if (!text.trim()) throw new EndpointDeliveryError('invalid_payload', 'Telegram button label must be nonempty', 'not_sent');
634
637
  if (record.disabled) return { text, disabled: {} };
638
+ if (url && /^https?:\/\//i.test(url)) return { text, url };
635
639
  if (Buffer.byteLength(callback, 'utf8') < 1 || Buffer.byteLength(callback, 'utf8') > 64) throw new EndpointDeliveryError('invalid_payload', 'Telegram callback payload must be 1-64 UTF-8 bytes and label nonempty', 'not_sent');
636
640
  return {
637
641
  text,