Skip to content

Commit c2a4d3f

Browse files
committed
feat: add bot reply support for link command
1 parent 7e1f965 commit c2a4d3f

6 files changed

Lines changed: 147 additions & 20 deletions

File tree

locales/en.ftl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,22 @@ cmd_link_help =
8181
Method 2: Provide both usernames
8282
— <code>/link &lt;github-username&gt; &lt;telegram-username&gt;</code>
8383
84+
Method 3: Reply to a bot event containing a GitHub user
85+
— <code>/link &lt;telegram-username&gt;</code>
86+
8487
Examples:
8588
— Reply to user: <code>/link ASafaeirad</code>
8689
— Direct: <code>/link ASafaeirad S_Kill</code>
90+
— Reply to bot event: <code>/link @S_Kill</code>
8791
8892
cmd_link = ✅ <b>Account linked successfully!</b>
8993
94+
cmd_link_event = ✅ GitHub user <b>{ $ghUsername }</b> has been linked to <b>{ $tgUsername }</b>.
95+
9096
cmd_link_no_user = ⚠️ Could not find user information.
9197
98+
cmd_link_no_github_user = ⚠️ Could not find a GitHub user in the replied-to bot message.
99+
92100
cmd_unlink_help =
93101
✍️ <code>/unlink</code> Guide:
94102
Pass the Telegram username after the command.

src/bot/commands/private/link.ts

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,86 @@
11
import type { SQLiteInsertValue, SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core";
2+
import type { MessageEntity, User } from "grammy/types";
23

34
import { config } from "#config";
45
import { db, schema as s } from "#db";
5-
import { createCommand, zs } from "#telegram";
6+
import { createCommand, extractGitHubUsername, zs } from "#telegram";
67
import z from "zod";
78

89
import type { BotContext } from "../../bot.ts";
910

11+
import { toTelegramFullName } from "../../../lib/telegram/telegram-user.ts";
12+
1013
const schema = z.object({
11-
ghUsername: zs.ghUsername,
14+
username: z.string().min(1),
1215
tgUsername: zs.tgUsername.optional(),
1316
});
1417

15-
export async function handler(ctx: BotContext<z.infer<typeof schema>>) {
16-
const repliedMessage = ctx.message.reply_to_message;
17-
const isActualReply = repliedMessage && !repliedMessage.forum_topic_created;
18+
type GitHubUsername = z.infer<typeof zs.ghUsername>;
19+
type TelegramUsername = z.infer<typeof zs.tgUsername>;
20+
type ResolvedLinkArguments =
21+
| { error: "cmd_link_help" | "cmd_link_no_github_user" }
22+
| { ghUsername: GitHubUsername; tgUsername: TelegramUsername | undefined };
23+
24+
function resolveLinkArguments(
25+
args: z.infer<typeof schema>,
26+
isReplyToBot: boolean,
27+
entities: readonly MessageEntity[] | undefined,
28+
): ResolvedLinkArguments {
29+
if (!isReplyToBot) {
30+
const result = zs.ghUsername.safeParse(args.username);
31+
return result.success ? { ghUsername: result.data, tgUsername: args.tgUsername } : { error: "cmd_link_help" };
32+
}
33+
34+
if (args.tgUsername) return { error: "cmd_link_help" };
1835

19-
const { ghUsername } = ctx.args;
20-
let { tgUsername } = ctx.args;
36+
const telegramResult = zs.tgUsername.safeParse(args.username);
37+
if (!telegramResult.success) return { error: "cmd_link_help" };
2138

22-
const from = isActualReply ? repliedMessage.from : undefined;
23-
const tgId = from?.id ?? null;
24-
const tgName = from ? [from.first_name, from.last_name].filter(Boolean).join(" ") : null;
39+
const ghUsername = extractGitHubUsername(entities);
40+
if (!ghUsername) return { error: "cmd_link_no_github_user" };
41+
42+
return { ghUsername, tgUsername: telegramResult.data };
43+
}
44+
45+
function getTelegramTarget(from: User | undefined, tgUsername: TelegramUsername | undefined) {
46+
const target = {
47+
tgId: from?.id ?? null,
48+
tgName: from ? toTelegramFullName(from) : null,
49+
tgUsername,
50+
};
2551

2652
if (from?.username) {
2753
const result = zs.tgUsername.safeParse(from.username);
28-
if (result.success) {
29-
tgUsername = result.data;
30-
}
54+
if (result.success) target.tgUsername = result.data;
3155
}
3256

33-
if (!tgId && !tgUsername) {
57+
return target;
58+
}
59+
60+
function getContributorValues(ghUsername: GitHubUsername, target: ReturnType<typeof getTelegramTarget>) {
61+
const set: SQLiteInsertValue<typeof s.contributors> = { ghUsername };
62+
if (target.tgId) set.tgId = target.tgId;
63+
if (target.tgName) set.tgName = target.tgName;
64+
if (target.tgUsername) set.tgUsername = target.tgUsername;
65+
return set;
66+
}
67+
68+
export async function handler(ctx: BotContext<z.infer<typeof schema>>) {
69+
const repliedMessage = ctx.message.reply_to_message;
70+
const entities = repliedMessage && "entities" in repliedMessage ? repliedMessage.entities : undefined;
71+
72+
const resolved = resolveLinkArguments(ctx.args, ctx.isReplyToBot, entities);
73+
74+
if ("error" in resolved) return await ctx.html.replyToMessage(ctx.t(resolved.error));
75+
76+
const from = ctx.isReply && !ctx.isReplyToBot ? repliedMessage?.from : undefined;
77+
const target = getTelegramTarget(from, resolved.tgUsername);
78+
79+
if (!target.tgId && !target.tgUsername) {
3480
return await ctx.html.replyToMessage(ctx.t("cmd_link_no_user"));
3581
}
3682

37-
const set: SQLiteInsertValue<typeof s.contributors> = { ghUsername };
38-
if (tgId) set.tgId = tgId;
39-
if (tgName) set.tgName = tgName;
40-
if (tgUsername) set.tgUsername = tgUsername;
83+
const set = getContributorValues(resolved.ghUsername, target);
4184

4285
await db
4386
.insert(s.contributors)
@@ -47,11 +90,20 @@ export async function handler(ctx: BotContext<z.infer<typeof schema>>) {
4790
set: set as SQLiteUpdateSetSource<typeof s.contributors>,
4891
});
4992

93+
if (ctx.isReplyToBot) {
94+
return await ctx.html.replyToMessage(
95+
ctx.t("cmd_link_event", {
96+
ghUsername: resolved.ghUsername,
97+
tgUsername: `@${target.tgUsername}`,
98+
}),
99+
);
100+
}
101+
50102
return await ctx.html.replyToMessage(ctx.t("cmd_link"));
51103
}
52104

53105
export const cmdLink = createCommand({
54-
template: "link $ghUsername $tgUsername",
106+
template: "link $username $tgUsername",
55107
description: "🛡 Link Telegram and GitHub accounts",
56108
handler,
57109
schema,

src/bot/middleware/helpers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { BotContext } from "#bot";
22

33
export const helpers = async (ctx: BotContext, next: () => Promise<unknown>) => {
4+
if (!ctx.message) return next();
5+
46
const repliedMessage = ctx.message.reply_to_message;
57
const isActualReply = Boolean(repliedMessage && !repliedMessage.forum_topic_created);
6-
const isReplyToBot = ctx.isReply && repliedMessage?.from?.id === ctx.me.id;
8+
const isReplyToBot = isActualReply && repliedMessage?.from?.id === ctx.me.id;
79

810
ctx.isReply = isActualReply;
911
ctx.isReplyToBot = isReplyToBot;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { MessageEntity } from "grammy/types";
2+
3+
import { expect, test } from "vitest";
4+
5+
import { extractGitHubUsername } from "./extract-github-username.ts";
6+
7+
const textLink = (url: string): MessageEntity => ({
8+
type: "text_link",
9+
offset: 0,
10+
length: 1,
11+
url,
12+
});
13+
14+
test("extracts a GitHub username from a profile link", () => {
15+
expect(extractGitHubUsername([textLink("https://github.com/ASafaeirad")])).toBe("ASafaeirad");
16+
});
17+
18+
test("skips repository and activity links before a profile link", () => {
19+
const entities = [
20+
textLink("https://github.com/fullstacksjs/github-bot"),
21+
textLink("https://github.com/fullstacksjs/github-bot/issues/91"),
22+
textLink("https://github.com/S-Kill"),
23+
];
24+
25+
expect(extractGitHubUsername(entities)).toBe("S-Kill");
26+
});
27+
28+
test("does not extract usernames from non-GitHub or malformed links", () => {
29+
const entities = [
30+
textLink("https://example.com/ASafaeirad"),
31+
textLink("https://github.com/-invalid"),
32+
textLink("not a URL"),
33+
];
34+
35+
expect(extractGitHubUsername(entities)).toBeUndefined();
36+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { MessageEntity } from "grammy/types";
2+
3+
import { ghUsername } from "./schemas.ts";
4+
5+
const isGitHubUrl = (url: URL) => url.protocol === "https:" && url.hostname.toLowerCase() === "github.com";
6+
7+
export function extractGitHubUsername(entities: readonly MessageEntity[] | undefined) {
8+
if (!entities?.length) return undefined;
9+
10+
for (const entity of entities) {
11+
if (entity.type !== "text_link") continue;
12+
13+
try {
14+
const url = new URL(entity.url);
15+
if (!isGitHubUrl(url)) continue;
16+
17+
const segments = url.pathname.split("/").filter(Boolean);
18+
if (segments.length !== 1) continue;
19+
20+
const result = ghUsername.safeParse(segments[0]);
21+
if (result.success) return result.data;
22+
} catch {
23+
return undefined;
24+
}
25+
}
26+
27+
return undefined;
28+
}

src/lib/telegram/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./CommandParser.ts";
22
export * from "./createCommand.ts";
3+
export * from "./extract-github-username.ts";
34
export * from "./render-markdown.ts";
45
export * as zs from "./schemas.ts";

0 commit comments

Comments
 (0)