@zhin.js/adapter-telegram 1.1.26 → 1.1.27

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/LICENSE +21 -0
  3. package/PERMITS.md +17 -13
  4. package/README.md +9 -5
  5. package/adapters/telegram/index.js +3 -0
  6. package/adapters/telegram/index.ts +3 -0
  7. package/agents/telegram/conversation.js +35 -0
  8. package/agents/telegram/conversation.ts +38 -0
  9. package/agents/telegram/messages.js +8 -0
  10. package/agents/telegram/messages.ts +8 -0
  11. package/agents/telegram/skills/telegram-chat-admin/tools/create_invite/index.js +6 -8
  12. package/agents/telegram/skills/telegram-chat-admin/tools/create_invite/index.ts +7 -9
  13. package/agents/telegram/skills/telegram-chat-admin/tools/list_admins/index.js +5 -7
  14. package/agents/telegram/skills/telegram-chat-admin/tools/list_admins/index.ts +6 -8
  15. package/agents/telegram/skills/telegram-chat-admin/tools/member_count/index.js +5 -7
  16. package/agents/telegram/skills/telegram-chat-admin/tools/member_count/index.ts +6 -8
  17. package/agents/telegram/skills/telegram-chat-admin/tools/pin_message/index.js +5 -6
  18. package/agents/telegram/skills/telegram-chat-admin/tools/pin_message/index.ts +6 -7
  19. package/agents/telegram/skills/telegram-chat-admin/tools/set_description/index.js +5 -4
  20. package/agents/telegram/skills/telegram-chat-admin/tools/set_description/index.ts +6 -5
  21. package/agents/telegram/skills/telegram-chat-admin/tools/unpin_message/index.js +5 -6
  22. package/agents/telegram/skills/telegram-chat-admin/tools/unpin_message/index.ts +6 -7
  23. package/agents/telegram/skills/telegram-group-permissions/tools/set_permissions/index.js +5 -6
  24. package/agents/telegram/skills/telegram-group-permissions/tools/set_permissions/index.ts +6 -7
  25. package/agents/telegram/skills/telegram-messages/tools/react/index.js +5 -6
  26. package/agents/telegram/skills/telegram-messages/tools/react/index.ts +6 -7
  27. package/agents/telegram/skills/telegram-messages/tools/send_poll/index.js +8 -7
  28. package/agents/telegram/skills/telegram-messages/tools/send_poll/index.ts +9 -8
  29. package/agents/telegram/skills/telegram-messages/tools/send_sticker/index.js +7 -7
  30. package/agents/telegram/skills/telegram-messages/tools/send_sticker/index.ts +8 -8
  31. package/lib/endpoint.d.ts +2 -1
  32. package/lib/endpoint.js +75 -21
  33. package/lib/index.d.ts +1 -1
  34. package/lib/index.js +1 -1
  35. package/lib/platform-permit.d.ts +16 -16
  36. package/lib/platform-permit.js +26 -45
  37. package/lib/protocol.d.ts +25 -4
  38. package/lib/protocol.js +31 -16
  39. package/lib/webhook.d.ts +1 -2
  40. package/lib/webhook.js +9 -20
  41. package/package.json +23 -27
  42. package/plugin.js +0 -6
  43. package/schema.json +0 -56
  44. package/src/endpoint.ts +73 -25
  45. package/src/index.ts +1 -3
  46. package/src/platform-permit.ts +27 -56
  47. package/src/protocol.ts +52 -20
  48. package/src/webhook.ts +11 -23
package/src/endpoint.ts CHANGED
@@ -3,7 +3,7 @@ import { createTelegramStreaming } from './streaming.js';
3
3
  import {
4
4
  createEndpointSpecification, endpointSpec, type Endpoint, type EndpointSpecification, createEndpointLifecycle,
5
5
  type EndpointLifecycle, type EndpointContentPort, type EndpointContentResolveContext,
6
- type EndpointControl, type EndpointSendRequest, type EndpointTransportState,
6
+ type EndpointControl, type EndpointManagement, type EndpointSendRequest, type EndpointTransportState,
7
7
  InboundDeduper, TtlLookupCache,
8
8
  } from 'zhin.js/adapter';
9
9
  /**
@@ -17,13 +17,14 @@ import {
17
17
  type ConversationReference,
18
18
  type ConversationResolution,
19
19
  type MessageRef,
20
+ type PlatformStanding,
20
21
  } from '@zhin.js/im-contract';
21
22
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
22
23
  import type { CapabilityId } from 'zhin.js';
23
24
  import { runTelegramPollLoop } from './polling.js';
24
25
  import { TelegramApiError } from './api-error.js';
25
26
  export { TelegramApiError } from './api-error.js';
26
- import { normalizeTelegramChatMember } from './platform-permit.js';
27
+ import { telegramChatMemberStanding } from './platform-permit.js';
27
28
  import {
28
29
  buildWebhookUrl,
29
30
  formatCallbackContent,
@@ -37,6 +38,7 @@ import {
37
38
  type ResolvedTelegramConfig,
38
39
  type TelegramCallbackQuery,
39
40
  type TelegramChatMember,
41
+ type TelegramChatMemberUpdated,
40
42
  type TelegramMessage,
41
43
  type TelegramOutboundUpload,
42
44
  type TelegramUpdate,
@@ -46,13 +48,13 @@ import { createTelegramSelfProfile } from './self-profile.js';
46
48
 
47
49
  const CHAT_MEMBER_CACHE_TTL_MS = 60_000;
48
50
  const CHAT_MEMBER_CACHE_MAX = 2_000;
51
+ /** A failed `getChatMember` is not retried for every message of a busy chat (unknown standing meanwhile). */
52
+ const CHAT_MEMBER_FAILURE_TTL_MS = 10_000;
49
53
  const ADMITTED_UPDATE_LIMIT = 2_048;
50
54
  const INFLIGHT_UPDATE_LIMIT = 256;
51
55
 
52
- interface ChatMemberPermit {
53
- readonly role?: string;
54
- readonly permissions: string[];
55
- }
56
+ /** ADR 0056 §1 normalized standing of one chat member (cached per chat + user). */
57
+ type ChatMemberPermit = PlatformStanding;
56
58
 
57
59
  /** One admitted platform message and its original framework lifetime. */
58
60
  interface TelegramMessageAdmission {
@@ -76,7 +78,8 @@ export { TelegramClient, type TelegramClientApi, type TelegramFetch } from './te
76
78
  /**
77
79
  * Telegram Bot API 无列表类接口(无 getMyChats/getChatMembers),
78
80
  * 仅 getChat/getChatMember 按已知 id 单查,不构成列表能力;
79
- * 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
81
+ * 因此本 endpoint 的 EndpointManagement 只有 `inspectSelfStanding`(ADR 0056 §1:getChatMember bot),
82
+ * Console 社交面 RPC 对该平台保持未接线。
80
83
  */
81
84
  export class TelegramEndpoint extends TelegramClient implements Endpoint {
82
85
  readonly [endpointSpec]: EndpointSpecification;
@@ -100,6 +103,23 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
100
103
  ttlMs: CHAT_MEMBER_CACHE_TTL_MS,
101
104
  maxEntries: CHAT_MEMBER_CACHE_MAX,
102
105
  });
106
+ /** Recently failed lookups (negative cache): the standing stays unknown until it expires. */
107
+ readonly #chatMemberFailures = new TtlLookupCache<string, true>({
108
+ ttlMs: CHAT_MEMBER_FAILURE_TTL_MS,
109
+ maxEntries: CHAT_MEMBER_CACHE_MAX,
110
+ });
111
+ /** Concurrent messages from one sender share one `getChatMember` call. */
112
+ readonly #chatMemberInflight = new Map<string, { promise?: Promise<ChatMemberPermit | undefined> }>();
113
+ readonly #management: EndpointManagement = Object.freeze<EndpointManagement>({
114
+ // ADR 0056 §1: the Bot's own standing (`bot:*` permits); the framework caches per conversation.
115
+ inspectSelfStanding: async ({ conversation, signal }) => {
116
+ if (conversation.kind === 'private' || !this.#botUserId) return undefined;
117
+ const member = await this.callApi<TelegramChatMember>('getChatMember', {
118
+ chat_id: Number(conversation.id), user_id: this.#botUserId,
119
+ }, signal);
120
+ return telegramChatMemberStanding(member);
121
+ },
122
+ });
103
123
  readonly #control: EndpointControl = Object.freeze<EndpointControl>({
104
124
  recall: ({ message, signal }) => this.recallMessage(message, signal),
105
125
  });
@@ -117,7 +137,7 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
117
137
  start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
118
138
  stop: () => this.#stopFramework(), send: request => this.#sendFramework(request),
119
139
  get name() { return options.config.id; }, get transportState() { return endpoint.#transportState; },
120
- control: this.#control, content: this.#content,
140
+ control: this.#control, content: this.#content, management: this.#management,
121
141
  streaming: options.config.streaming !== false ? createTelegramStreaming(() => this, options.config.streamingIntervalMs) : undefined,
122
142
  profile: createTelegramSelfProfile({ client: this,
123
143
  current: () => this.#botName === undefined ? {} : { nickname: this.#botName },
@@ -409,7 +429,7 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
409
429
  sender: {
410
430
  id: String(msg.from?.id ?? ''),
411
431
  name: senderDisplayName(msg.from) || undefined,
412
- ...(permit?.role ? { roles: [permit.role] } : {}),
432
+ ...(permit ? { platform: permit } : {}),
413
433
  },
414
434
  endpointId: this.#options.config.id,
415
435
  ...(mentioned ? { mentioned: true } : {}),
@@ -419,8 +439,6 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
419
439
  chatType: msg.chat.type,
420
440
  userId: msg.from?.id,
421
441
  date: msg.date,
422
- ...(permit?.role ? { senderRole: permit.role } : {}),
423
- ...(permit?.permissions.length ? { senderPermissions: [...permit.permissions] } : {}),
424
442
  }),
425
443
  });
426
444
  }
@@ -446,7 +464,10 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
446
464
  return false;
447
465
  }
448
466
 
449
- /** 群消息 sender role 解析:getChatMember + 60s 缓存(对齐旧 enrichGroupSender)。 */
467
+ /**
468
+ * 群消息 sender role 解析:getChatMember + 60s 缓存;并发查询合并为一次调用,失败负缓存 10s,
469
+ * `chat_member` / `my_chat_member` 更新时以新状态覆盖缓存。
470
+ */
450
471
  async #resolveGroupSenderPermit(msg: TelegramMessage, signal: AbortSignal): Promise<ChatMemberPermit | undefined> {
451
472
  if (msg.chat.type === 'private' || !msg.from?.id) return undefined;
452
473
  const chatId = Number(msg.chat.id);
@@ -454,20 +475,45 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
454
475
  const key = `${chatId}:${userId}`;
455
476
  const cached = this.#chatMemberCache.get(key);
456
477
  if (cached) return cached;
457
- try {
458
- const member = await this.callApi<TelegramChatMember>('getChatMember', {
459
- chat_id: chatId,
460
- user_id: userId,
461
- }, signal);
462
- if (signal.aborted) return undefined;
463
- const normalized = normalizeTelegramChatMember(member);
464
- const entry: ChatMemberPermit = { ...normalized };
465
- this.#chatMemberCache.set(key, entry);
466
- return entry;
467
- } catch {
468
- // 保守拒绝:无角色快照
469
- return undefined;
478
+ if (this.#chatMemberFailures.get(key)) return undefined;
479
+ let task = this.#chatMemberInflight.get(key)?.promise;
480
+ if (!task) {
481
+ const lookup: { promise?: Promise<ChatMemberPermit | undefined> } = {};
482
+ task = (async () => {
483
+ await undefined; // registered in #chatMemberInflight before the body runs
484
+ try {
485
+ const member = await this.callApi<TelegramChatMember>('getChatMember', {
486
+ chat_id: chatId,
487
+ user_id: userId,
488
+ }, signal);
489
+ if (signal.aborted) return undefined;
490
+ const entry: ChatMemberPermit = telegramChatMemberStanding(member);
491
+ // Invalidated (chat_member update) while in flight: do not store the older answer.
492
+ if (this.#chatMemberInflight.get(key) === lookup) this.#chatMemberCache.set(key, entry);
493
+ return entry;
494
+ } catch {
495
+ // 保守拒绝:无角色快照;取消不算失败
496
+ if (!signal.aborted && this.#chatMemberInflight.get(key) === lookup) this.#chatMemberFailures.set(key, true);
497
+ return undefined;
498
+ } finally {
499
+ if (this.#chatMemberInflight.get(key) === lookup) this.#chatMemberInflight.delete(key);
500
+ }
501
+ })();
502
+ lookup.promise = task;
503
+ this.#chatMemberInflight.set(key, lookup);
470
504
  }
505
+ const entry = await task;
506
+ return signal.aborted ? undefined : entry;
507
+ }
508
+
509
+ /** `chat_member` / `my_chat_member`: the update carries the new status, so it replaces the cached one. */
510
+ #applyChatMemberUpdate(update: TelegramChatMemberUpdated | undefined): void {
511
+ const userId = update?.new_chat_member?.user?.id;
512
+ if (!update?.chat || userId == null) return;
513
+ const key = `${Number(update.chat.id)}:${userId}`;
514
+ this.#chatMemberInflight.delete(key);
515
+ this.#chatMemberFailures.delete(key);
516
+ this.#chatMemberCache.set(key, telegramChatMemberStanding(update.new_chat_member));
471
517
  }
472
518
 
473
519
  /** Test / internal: admit a callback query when open. */
@@ -544,6 +590,8 @@ export class TelegramEndpoint extends TelegramClient implements Endpoint {
544
590
  if (update.message || update.callback_query) {
545
591
  await this[endpointSpec].events.platform(eventName, update);
546
592
  } else {
593
+ this.#applyChatMemberUpdate(update.chat_member);
594
+ this.#applyChatMemberUpdate(update.my_chat_member);
547
595
  await this[endpointSpec].events.emitAccepted('platform.receive', Object.freeze({ name: eventName, event: update }));
548
596
  return;
549
597
  }
package/src/index.ts CHANGED
@@ -33,10 +33,8 @@ export { type TelegramClientEventMap } from './client.js';
33
33
  export { TelegramAdapter } from './adapter.js';
34
34
 
35
35
  export {
36
- checkTelegramPlatformPermit,
37
- normalizeTelegramChatMember,
36
+ telegramChatMemberStanding,
38
37
  platformPermit,
39
- telegramGroupPermitResolver,
40
38
  } from './platform-permit.js';
41
39
 
42
40
  export type { TelegramPollRequest } from './telegram-client.js';
@@ -1,68 +1,39 @@
1
1
  /**
2
- * Telegram platform permit — ChatMember 状态 + 细粒度权限
2
+ * Telegram platform standing — ChatMember 状态 + 管理员权限(ADR 0056 §1)
3
3
  *
4
- * `role` 使用跨平台规范名 owner / admin(原生 creator / administrator 仅作兼容输入);
5
- * 细粒度能力放在 `permissions`。
4
+ * `standing` 使用跨平台规范名 owner / admin / member / guest;细粒度管理员权限放在 `permissions`。
6
5
  */
7
- import type { PermissionSubject } from '@zhin.js/permission';
8
-
9
- const ADAPTER = 'telegram';
6
+ import type { PlatformStanding } from '@zhin.js/im-contract';
10
7
 
11
8
  export function platformPermit(perm: string): string {
12
- return `platform(${ADAPTER},${perm})`;
9
+ return `platform-permission:${perm}`;
13
10
  }
14
11
 
15
- const FACTORY_PERM_MAP: Record<string, string> = {
16
- scene_admin: 'chat_administrator',
17
- scene_owner: 'chat_creator',
18
- };
19
-
20
- export function telegramGroupPermitResolver(logicalPerm: string): string {
21
- return platformPermit(FACTORY_PERM_MAP[logicalPerm] ?? logicalPerm);
22
- }
12
+ /** Administrator rights a chat creator implicitly holds (Bot API `ChatAdministratorRights`). */
13
+ const TELEGRAM_ADMIN_RIGHTS = [
14
+ 'manage_chat', 'delete_messages', 'manage_video_chats', 'restrict_members', 'promote_members', 'change_info',
15
+ 'invite_users', 'post_stories', 'edit_stories', 'delete_stories', 'post_messages', 'edit_messages', 'pin_messages',
16
+ 'manage_topics',
17
+ ] as const;
23
18
 
24
- export function normalizeTelegramChatMember(member: {
25
- status?: string;
26
- can_restrict_members?: boolean;
27
- can_pin_messages?: boolean;
28
- can_delete_messages?: boolean;
29
- can_manage_chat?: boolean;
30
- }): { role?: string; permissions: string[] } {
31
- const permissions: string[] = [];
19
+ /**
20
+ * ADR 0056 §1:`getChatMember` 结果 → `sender.platform`。
21
+ * creator → `owner`(拥有全部管理员权限);administrator → `admin`,其 `can_*` 权限去掉前缀进
22
+ * `permissions`(如 `restrict_members`、`pin_messages`);member / 仍在群内的 restricted → `member`;
23
+ * left / kicked → `guest`。Telegram 没有身份组,`roles` 不上报。
24
+ *
25
+ * 只有管理员权限作为平台权限上报:restricted 成员的 `can_*`(如 `can_pin_messages`)是对其个人的
26
+ * 发言许可,普通成员同样可能按群默认权限拥有,两者都不上报,`platform-permission:*` 只由管理员满足。
27
+ */
28
+ export function telegramChatMemberStanding(member: { readonly status?: string; readonly is_member?: boolean }): PlatformStanding {
32
29
  const status = member.status;
33
- if (status === 'creator') {
34
- permissions.push('chat_creator', 'chat_administrator', 'restrict_members', 'pin_messages');
35
- return { role: 'owner', permissions };
36
- }
30
+ if (status === 'creator') return { standing: 'owner', permissions: [...TELEGRAM_ADMIN_RIGHTS] };
37
31
  if (status === 'administrator') {
38
- permissions.push('chat_administrator');
39
- if (member.can_restrict_members) permissions.push('restrict_members');
40
- if (member.can_pin_messages) permissions.push('pin_messages');
41
- if (member.can_delete_messages) permissions.push('delete_messages');
42
- if (member.can_manage_chat) permissions.push('manage_chat');
43
- return { role: 'admin', permissions };
44
- }
45
- return { role: 'member', permissions };
46
- }
47
-
48
- export function checkTelegramPlatformPermit(perm: string, subject: PermissionSubject): boolean {
49
- const role = subject.sender?.role?.[0];
50
- const permissions = subject.sender?.permissions ?? [];
51
- const has = (t: string) => permissions.includes(t);
52
-
53
- switch (perm) {
54
- case 'chat_creator':
55
- return role === 'owner' || role === 'creator' || has('chat_creator');
56
- case 'chat_administrator':
57
- return role === 'owner' || role === 'admin' || role === 'creator' || role === 'administrator' || has('chat_administrator');
58
- case 'restrict_members':
59
- return has('restrict_members') || role === 'owner' || role === 'creator';
60
- case 'pin_messages':
61
- return has('pin_messages') || role === 'owner' || role === 'creator';
62
- case 'manage_chat':
63
- return has('manage_chat') || role === 'owner' || role === 'creator';
64
- default:
65
- return false;
32
+ const granted = Object.entries(member as Readonly<Record<string, unknown>>)
33
+ .filter(([key, value]) => key.startsWith('can_') && value === true)
34
+ .map(([key]) => key.slice(4));
35
+ return { standing: 'admin', ...(granted.length ? { permissions: granted } : {}) };
66
36
  }
37
+ if (status === 'left' || status === 'kicked' || (status === 'restricted' && member.is_member === false)) return { standing: 'guest' };
38
+ return { standing: 'member' };
67
39
  }
68
-
package/src/protocol.ts CHANGED
@@ -4,7 +4,6 @@ import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
4
4
  * Canonicalization is owned by gateway/core before endpoint.send.
5
5
  */
6
6
 
7
- import type { IncomingMessage } from 'node:http';
8
7
  import { EndpointDeliveryError, isMediaRef, type ConversationKind, type ConversationRef } from '@zhin.js/im-contract';
9
8
  import type { Segment } from '@zhin.js/core/runtime';
10
9
  import { formatCompact, getLogger } from '@zhin.js/logger';
@@ -144,6 +143,18 @@ export interface TelegramUpdate {
144
143
  readonly message?: TelegramMessage;
145
144
  readonly edited_message?: TelegramMessage;
146
145
  readonly callback_query?: TelegramCallbackQuery;
146
+ /** A member's status changed (only delivered when `allowed_updates` lists it and the Bot is an admin). */
147
+ readonly chat_member?: TelegramChatMemberUpdated;
148
+ /** The Bot's own member status changed. */
149
+ readonly my_chat_member?: TelegramChatMemberUpdated;
150
+ }
151
+
152
+ export interface TelegramChatMemberUpdated {
153
+ readonly chat: TelegramChat;
154
+ readonly from?: TelegramUser;
155
+ readonly date?: number;
156
+ readonly old_chat_member?: TelegramChatMember;
157
+ readonly new_chat_member: TelegramChatMember;
147
158
  }
148
159
 
149
160
  export interface TelegramChatMember {
@@ -153,6 +164,8 @@ export interface TelegramChatMember {
153
164
  readonly can_pin_messages?: boolean;
154
165
  readonly can_delete_messages?: boolean;
155
166
  readonly can_manage_chat?: boolean;
167
+ /** `restricted` only: whether the user is still a member of the chat. */
168
+ readonly is_member?: boolean;
156
169
  }
157
170
 
158
171
  export interface TelegramWireSegment {
@@ -245,6 +258,17 @@ export type TelegramOutboundAction =
245
258
  readonly longitude: number;
246
259
  readonly reply_parameters?: { readonly message_id: number };
247
260
  };
261
+ }
262
+ | {
263
+ readonly method: 'sendPoll';
264
+ readonly params: {
265
+ readonly chat_id: number | string;
266
+ readonly question: string;
267
+ readonly options: readonly string[];
268
+ readonly is_anonymous: boolean;
269
+ readonly allows_multiple_answers: boolean;
270
+ readonly reply_parameters?: { readonly message_id: number };
271
+ };
248
272
  };
249
273
 
250
274
  export function resolveTelegramConfig(config: TelegramEndpointConfig): ResolvedTelegramConfig {
@@ -256,7 +280,8 @@ export function resolveTelegramConfig(config: TelegramEndpointConfig): ResolvedT
256
280
  const webhookSource = config.webhook;
257
281
  const mode: 'polling' | 'webhook' = polling === false ? 'webhook' : 'polling';
258
282
  const apiBaseUrl = (config.apiBaseUrl ?? 'https://api.telegram.org').replace(/\/$/, '');
259
- const allowedUpdates = config.allowedUpdates ?? ['message', 'callback_query'];
283
+ // chat_member / my_chat_member keep the cached sender standing fresh (ADR 0056 §1).
284
+ const allowedUpdates = config.allowedUpdates ?? ['message', 'callback_query', 'chat_member', 'my_chat_member'];
260
285
  const webhook = mode === 'webhook'
261
286
  ? {
262
287
  domain: webhookSource?.domain ?? '',
@@ -297,24 +322,6 @@ export function buildWebhookUrl(webhook: NonNullable<ResolvedTelegramConfig['web
297
322
  return `${domain}${webhook.path}`;
298
323
  }
299
324
 
300
- export async function readTextBody(
301
- request: IncomingMessage,
302
- options: { readonly limit?: number } = {},
303
- ): Promise<string> {
304
- const limit = options.limit ?? 1_048_576;
305
- const chunks: Buffer[] = [];
306
- let size = 0;
307
- for await (const chunk of request) {
308
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
309
- size += buffer.length;
310
- if (size > limit) {
311
- request.destroy();
312
- throw new Error(`Request body exceeds ${limit} bytes`);
313
- }
314
- chunks.push(buffer);
315
- }
316
- return Buffer.concat(chunks).toString('utf8');
317
- }
318
325
 
319
326
  export function botApiUrl(config: Pick<ResolvedTelegramConfig, 'apiBaseUrl' | 'token'>, method: string): string {
320
327
  return `${config.apiBaseUrl}/bot${config.token}/${method}`;
@@ -746,6 +753,31 @@ function buildOutboundActions(
746
753
  }
747
754
  break;
748
755
  }
756
+ case 'telegram:sticker': {
757
+ // Agent-authored sticker: an existing Telegram file_id or an http(s) URL only, never a host path.
758
+ const sticker = typeof data.sticker === 'string' ? data.sticker.trim() : '';
759
+ if (!sticker || /^(?:file:|base64:|attach:)/i.test(sticker) || (/[\\/]/.test(sticker) && !/^https?:\/\//i.test(sticker))) {
760
+ throw new EndpointDeliveryError('invalid_payload', 'telegram:sticker requires a Telegram file_id or an http(s) URL', 'not_sent');
761
+ }
762
+ flushText();
763
+ actions.push({ method: 'sendSticker', params: { chat_id: chatId, sticker, ...replyParams() } });
764
+ break;
765
+ }
766
+ case 'telegram:poll': {
767
+ const question = typeof data.question === 'string' ? data.question.trim() : '';
768
+ const options = Array.isArray(data.options) ? data.options.map(String).filter(Boolean) : [];
769
+ if (!question || options.length < 2) {
770
+ throw new EndpointDeliveryError('invalid_payload', 'telegram:poll requires a question and at least two options', 'not_sent');
771
+ }
772
+ flushText();
773
+ actions.push({ method: 'sendPoll', params: {
774
+ chat_id: chatId, question, options,
775
+ is_anonymous: data.is_anonymous !== false,
776
+ allows_multiple_answers: data.allows_multiple_answers === true,
777
+ ...replyParams(),
778
+ } });
779
+ break;
780
+ }
749
781
  case 'location': {
750
782
  flushText();
751
783
  actions.push({
package/src/webhook.ts CHANGED
@@ -2,10 +2,10 @@
2
2
  * Telegram webhook HTTP: secret token → parse → handle update.
3
3
  */
4
4
  import { timingSafeEqual } from 'node:crypto';
5
- import type { IncomingMessage, ServerResponse } from 'node:http';
6
5
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
+ import { readTextBody } from '@zhin.js/host-http-contract';
7
7
  import { getLogger } from '@zhin.js/logger';
8
- import { readTextBody, type ResolvedTelegramConfig, type TelegramUpdate } from './protocol.js';
8
+ import { type ResolvedTelegramConfig, type TelegramUpdate } from './protocol.js';
9
9
 
10
10
  const logger = getLogger('telegram');
11
11
 
@@ -28,26 +28,20 @@ export function registerTelegramWebhookRoutes(
28
28
  ): HttpRouteRegistration[] {
29
29
  const path = handler.config.webhook!.path;
30
30
  return [
31
- http.route('POST', path, async (request, response) => {
32
- await handleTelegramWebhookRequest(request, response, handler);
33
- }, { summary: 'Telegram Bot API webhook', tags: ['telegram'] }),
31
+ http.route('POST', path, (request) => handleTelegramWebhookRequest(request, handler), { summary: 'Telegram Bot API webhook', tags: ['telegram'] }),
34
32
  ];
35
33
  }
36
34
 
37
35
  export async function handleTelegramWebhookRequest(
38
- request: IncomingMessage,
39
- response: ServerResponse,
36
+ request: Request,
40
37
  handler: TelegramWebhookHandler,
41
- ): Promise<void> {
38
+ ): Promise<Response> {
42
39
  try {
43
40
  const secret = handler.config.webhook?.secretToken;
44
41
  if (secret) {
45
- const header = request.headers['x-telegram-bot-api-secret-token'];
46
- const token = Array.isArray(header) ? header[0] : header;
42
+ const token = request.headers.get('x-telegram-bot-api-secret-token');
47
43
  if (!token || !safeTokenEqual(token, secret)) {
48
- response.writeHead(403, { 'Content-Type': 'application/json' });
49
- response.end(JSON.stringify({ ok: false, description: 'Invalid secret token' }));
50
- return;
44
+ return new Response(JSON.stringify({ ok: false, description: 'Invalid secret token' }), { status: 403, headers: { 'Content-Type': 'application/json' } });
51
45
  }
52
46
  }
53
47
 
@@ -56,23 +50,17 @@ export async function handleTelegramWebhookRequest(
56
50
  try {
57
51
  update = JSON.parse(rawBody) as TelegramUpdate;
58
52
  } catch {
59
- response.writeHead(400, { 'Content-Type': 'application/json' });
60
- response.end(JSON.stringify({ ok: false, description: 'Invalid JSON' }));
61
- return;
53
+ return new Response(JSON.stringify({ ok: false, description: 'Invalid JSON' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
62
54
  }
63
55
  if (!update || !Number.isSafeInteger(update.update_id) || update.update_id < 0) {
64
- response.writeHead(400, { 'Content-Type': 'application/json' });
65
- response.end(JSON.stringify({ ok: false, description: 'Invalid update_id' }));
66
- return;
56
+ return new Response(JSON.stringify({ ok: false, description: 'Invalid update_id' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
67
57
  }
68
58
  if (!handler.isOpen) throw new Error('Telegram endpoint is not accepting updates');
69
59
  await handler.handleUpdate(update);
70
60
 
71
- response.writeHead(200, { 'Content-Type': 'application/json' });
72
- response.end(JSON.stringify({ ok: true }));
61
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
73
62
  } catch (error) {
74
63
  logger.error('Telegram webhook error:', error);
75
- response.writeHead(503, { 'Content-Type': 'application/json' });
76
- response.end(JSON.stringify({ ok: false, description: 'Update was not admitted' }));
64
+ return new Response(JSON.stringify({ ok: false, description: 'Update was not admitted' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
77
65
  }
78
66
  }