Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/telegram-mention-on-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/telegram": minor
---

Add `mentionOnReply`: when enabled, a reply to one of the bot own messages reports `isMention`, so a bot in a group keeps the conversation going without the handle being repeated. Off by default — existing mention-only bots are unaffected — and readable from `TELEGRAM_MENTION_ON_REPLY`.
5 changes: 5 additions & 0 deletions .changeset/telegram-native-replies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/telegram": minor
---

Implement `reply` in the Telegram adapter so `Thread.reply()` threads the answer to its target instead of throwing `NotImplementedError`. The reference travels as Bot API `reply_parameters` and covers text, rich messages, documents, attachments and media groups; `allow_sending_without_reply` keeps delivery working when the target has been deleted.
154 changes: 154 additions & 0 deletions packages/adapter-telegram/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5856,3 +5856,157 @@ describe("mention regex caching", () => {
expect(adapter.checkMention("hi @first_bot")).toBe(false);
});
});

describe("reply", () => {
function createReplyAdapter() {
return createTelegramAdapter({
botToken: "token",
mode: "webhook",
logger: mockLogger,
userName: "mybot",
});
}

it("threads a text message to its target", async () => {
mockFetch
.mockResolvedValueOnce(
telegramOk({ id: 1, is_bot: true, username: "mybot" })
)
.mockResolvedValueOnce(telegramOk(sampleMessage({ message_id: 11 })));

const adapter = createReplyAdapter();
await adapter.initialize(createMockChat());

await adapter.reply("telegram:123", "123:7", { markdown: "hello" });

const body = JSON.parse(
String((mockFetch.mock.calls[1]?.[1] as RequestInit).body)
);
expect(body.reply_parameters).toEqual({
message_id: 7,
allow_sending_without_reply: true,
});
});

it("leaves a plain postMessage unthreaded", async () => {
mockFetch
.mockResolvedValueOnce(
telegramOk({ id: 1, is_bot: true, username: "mybot" })
)
.mockResolvedValueOnce(telegramOk(sampleMessage({ message_id: 12 })));

const adapter = createReplyAdapter();
await adapter.initialize(createMockChat());

await adapter.postMessage("telegram:123", { markdown: "hello" });

const body = JSON.parse(
String((mockFetch.mock.calls[1]?.[1] as RequestInit).body)
);
expect(body.reply_parameters).toBeUndefined();
});

it("refuses a target that belongs to another chat", async () => {
mockFetch.mockResolvedValueOnce(
telegramOk({ id: 1, is_bot: true, username: "mybot" })
);

const adapter = createReplyAdapter();
await adapter.initialize(createMockChat());

await expect(
adapter.reply("telegram:123", "999:7", { markdown: "hello" })
).rejects.toThrow("chat mismatch");
});
});

describe("mentionOnReply", () => {
const BOT_USER_ID = 8981792219;

async function deliverReply(options: {
mentionOnReply?: boolean;
replyFromBot: boolean;
}) {
mockFetch.mockResolvedValue(
telegramOk({
id: BOT_USER_ID,
is_bot: true,
first_name: "Bot",
username: "mybot",
})
);
const chat = createMockChatInstance({
logger: mockLogger,
state: createMockState(),
userName: "mybot",
});
const adapter = createTelegramAdapter({
botToken: "token",
mode: "webhook",
logger: mockLogger,
userName: "mybot",
...(options.mentionOnReply === undefined
? {}
: { mentionOnReply: options.mentionOnReply }),
});
await adapter.initialize(chat);

await adapter.handleWebhook(
new Request("https://example.com/webhook", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
update_id: 1,
message: sampleMessage({
chat: { id: -100123, type: "supergroup", title: "General" },
text: "and the second one?",
reply_to_message: sampleMessage({
message_id: 5,
chat: { id: -100123, type: "supergroup", title: "General" },
from: options.replyFromBot
? {
id: BOT_USER_ID,
is_bot: true,
first_name: "Bot",
username: "mybot",
}
: {
id: 777,
is_bot: false,
first_name: "Someone",
username: "someone",
},
}),
}),
}),
})
);

const processMessage = chat.processMessage as ReturnType<typeof vi.fn>;
const call = processMessage.mock.calls[0] as
| [unknown, string, { isMention?: boolean }]
| undefined;
return call?.[2];
}

it("counts a reply to the bot as a mention when enabled", async () => {
const parsed = await deliverReply({
mentionOnReply: true,
replyFromBot: true,
});
expect(parsed?.isMention).toBe(true);
});

it("ignores a reply to somebody else", async () => {
const parsed = await deliverReply({
mentionOnReply: true,
replyFromBot: false,
});
expect(parsed?.isMention).toBe(false);
});

it("stays off by default so existing bots keep mention-only behaviour", async () => {
const parsed = await deliverReply({ replyFromBot: true });
expect(parsed?.isMention).toBe(false);
});
});
Loading