diff --git a/locales/en.ftl b/locales/en.ftl
index fa2500e..1ec0dce 100644
--- a/locales/en.ftl
+++ b/locales/en.ftl
@@ -81,14 +81,22 @@ cmd_link_help =
Method 2: Provide both usernames
— /link <github-username> <telegram-username>
+ Method 3: Reply to a bot event containing a GitHub user
+ — /link <telegram-username>
+
Examples:
— Reply to user: /link ASafaeirad
— Direct: /link ASafaeirad S_Kill
+ — Reply to bot event: /link @S_Kill
cmd_link = ✅ Account linked successfully!
+cmd_link_event = ✅ GitHub user { $ghUsername } has been linked to { $tgUsername }.
+
cmd_link_no_user = ⚠️ Could not find user information.
+cmd_link_no_github_user = ⚠️ Could not find a GitHub user in the replied-to bot message.
+
cmd_unlink_help =
✍️ /unlink Guide:
Pass the Telegram username after the command.
diff --git a/src/bot/bot.ts b/src/bot/bot.ts
index c0cbdd5..5f2f83b 100644
--- a/src/bot/bot.ts
+++ b/src/bot/bot.ts
@@ -7,11 +7,13 @@ import { limit } from "@grammyjs/ratelimiter";
import { config } from "#config";
import { Bot as GrammyBot } from "grammy";
+import type { HelperContext } from "./middleware/helpers.ts";
import type { LoggerContext } from "./middleware/logger.ts";
import type { MarkupContext } from "./middleware/markup.ts";
import { adminCommands } from "./commands/private/group.ts";
import { userCommands } from "./commands/public/group.ts";
+import { helpers } from "./middleware/helpers.ts";
import { logger } from "./middleware/logger.ts";
import { markup } from "./middleware/markup.ts";
@@ -39,6 +41,7 @@ type CommandContext = { args: Args } & { message: { text: string } };
export type BotContext> = CommandContext &
Context &
+ HelperContext &
I18nFlavor &
LoggerContext &
MarkupContext;
@@ -65,6 +68,7 @@ export class Bot extends GrammyBot {
this.polling = polling === true ? {} : polling;
this.use(markup);
this.use(logger);
+ this.use(helpers);
this.use(this.i18n);
this.use(
limit({
diff --git a/src/bot/commands/private/link.ts b/src/bot/commands/private/link.ts
index 08b22aa..172d26e 100644
--- a/src/bot/commands/private/link.ts
+++ b/src/bot/commands/private/link.ts
@@ -1,43 +1,86 @@
import type { SQLiteInsertValue, SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core";
+import type { MessageEntity, User } from "grammy/types";
import { config } from "#config";
import { db, schema as s } from "#db";
-import { createCommand, zs } from "#telegram";
+import { createCommand, extractGitHubUsername, zs } from "#telegram";
import z from "zod";
import type { BotContext } from "../../bot.ts";
+import { toTelegramFullName } from "../../../lib/telegram/telegram-user.ts";
+
const schema = z.object({
- ghUsername: zs.ghUsername,
+ username: z.string().min(1),
tgUsername: zs.tgUsername.optional(),
});
-export async function handler(ctx: BotContext>) {
- const repliedMessage = ctx.message.reply_to_message;
- const isActualReply = repliedMessage && !repliedMessage.forum_topic_created;
+type GitHubUsername = z.infer;
+type TelegramUsername = z.infer;
+type ResolvedLinkArguments =
+ | { error: "cmd_link_help" | "cmd_link_no_github_user" }
+ | { ghUsername: GitHubUsername; tgUsername: TelegramUsername | undefined };
+
+function resolveLinkArguments(
+ args: z.infer,
+ isReplyToBot: boolean,
+ entities: readonly MessageEntity[] | undefined,
+): ResolvedLinkArguments {
+ if (!isReplyToBot) {
+ const result = zs.ghUsername.safeParse(args.username);
+ return result.success ? { ghUsername: result.data, tgUsername: args.tgUsername } : { error: "cmd_link_help" };
+ }
+
+ if (args.tgUsername) return { error: "cmd_link_help" };
- const { ghUsername } = ctx.args;
- let { tgUsername } = ctx.args;
+ const telegramResult = zs.tgUsername.safeParse(args.username);
+ if (!telegramResult.success) return { error: "cmd_link_help" };
- const from = isActualReply ? repliedMessage.from : undefined;
- const tgId = from?.id ?? null;
- const tgName = from ? [from.first_name, from.last_name].filter(Boolean).join(" ") : null;
+ const ghUsername = extractGitHubUsername(entities);
+ if (!ghUsername) return { error: "cmd_link_no_github_user" };
+
+ return { ghUsername, tgUsername: telegramResult.data };
+}
+
+function getTelegramTarget(from: User | undefined, tgUsername: TelegramUsername | undefined) {
+ const target = {
+ tgId: from?.id ?? null,
+ tgName: from ? toTelegramFullName(from) : null,
+ tgUsername,
+ };
if (from?.username) {
const result = zs.tgUsername.safeParse(from.username);
- if (result.success) {
- tgUsername = result.data;
- }
+ if (result.success) target.tgUsername = result.data;
}
- if (!tgId && !tgUsername) {
+ return target;
+}
+
+function getContributorValues(ghUsername: GitHubUsername, target: ReturnType) {
+ const set: SQLiteInsertValue = { ghUsername };
+ if (target.tgId) set.tgId = target.tgId;
+ if (target.tgName) set.tgName = target.tgName;
+ if (target.tgUsername) set.tgUsername = target.tgUsername;
+ return set;
+}
+
+export async function handler(ctx: BotContext>) {
+ const repliedMessage = ctx.message.reply_to_message;
+ const entities = repliedMessage && "entities" in repliedMessage ? repliedMessage.entities : undefined;
+
+ const resolved = resolveLinkArguments(ctx.args, ctx.isReplyToBot, entities);
+
+ if ("error" in resolved) return await ctx.html.replyToMessage(ctx.t(resolved.error));
+
+ const from = ctx.isReply && !ctx.isReplyToBot ? repliedMessage?.from : undefined;
+ const target = getTelegramTarget(from, resolved.tgUsername);
+
+ if (!target.tgId && !target.tgUsername) {
return await ctx.html.replyToMessage(ctx.t("cmd_link_no_user"));
}
- const set: SQLiteInsertValue = { ghUsername };
- if (tgId) set.tgId = tgId;
- if (tgName) set.tgName = tgName;
- if (tgUsername) set.tgUsername = tgUsername;
+ const set = getContributorValues(resolved.ghUsername, target);
await db
.insert(s.contributors)
@@ -47,11 +90,20 @@ export async function handler(ctx: BotContext>) {
set: set as SQLiteUpdateSetSource,
});
+ if (ctx.isReplyToBot) {
+ return await ctx.html.replyToMessage(
+ ctx.t("cmd_link_event", {
+ ghUsername: resolved.ghUsername,
+ tgUsername: `@${target.tgUsername}`,
+ }),
+ );
+ }
+
return await ctx.html.replyToMessage(ctx.t("cmd_link"));
}
export const cmdLink = createCommand({
- template: "link $ghUsername $tgUsername",
+ template: "link $username $tgUsername",
description: "🛡 Link Telegram and GitHub accounts",
handler,
schema,
diff --git a/src/bot/commands/public/whoami.ts b/src/bot/commands/public/whoami.ts
index 19abb68..2c2b205 100644
--- a/src/bot/commands/public/whoami.ts
+++ b/src/bot/commands/public/whoami.ts
@@ -6,6 +6,7 @@ import { eq } from "drizzle-orm";
import type { BotContext } from "../../bot.ts";
import { escapeHtml } from "../../../lib/escape-html.ts";
+import { toTelegramFullName } from "../../../lib/telegram/telegram-user.ts";
export async function handler(ctx: BotContext) {
const sender = ctx.message.from;
@@ -19,7 +20,7 @@ export async function handler(ctx: BotContext) {
return await ctx.replyToMessage(ctx.t("cmd_whoami_not_found"));
}
- const fullName = `${sender.first_name} ${sender.last_name ?? ""}`;
+ const fullName = toTelegramFullName(sender);
const githubUrl = `https://github.com/${ghUser.ghUsername}`;
// Make user's Telegram information fresh.
diff --git a/src/bot/middleware/helpers.ts b/src/bot/middleware/helpers.ts
new file mode 100644
index 0000000..558b403
--- /dev/null
+++ b/src/bot/middleware/helpers.ts
@@ -0,0 +1,19 @@
+import type { BotContext } from "#bot";
+
+export const helpers = async (ctx: BotContext, next: () => Promise) => {
+ if (!ctx.message) return next();
+
+ const repliedMessage = ctx.message.reply_to_message;
+ const isActualReply = Boolean(repliedMessage && !repliedMessage.forum_topic_created);
+ const isReplyToBot = isActualReply && repliedMessage?.from?.id === ctx.me.id;
+
+ ctx.isReply = isActualReply;
+ ctx.isReplyToBot = isReplyToBot;
+
+ return next();
+};
+
+export interface HelperContext {
+ isReply: boolean;
+ isReplyToBot: boolean;
+}
diff --git a/src/lib/telegram/extract-github-username.spec.ts b/src/lib/telegram/extract-github-username.spec.ts
new file mode 100644
index 0000000..4a9683f
--- /dev/null
+++ b/src/lib/telegram/extract-github-username.spec.ts
@@ -0,0 +1,36 @@
+import type { MessageEntity } from "grammy/types";
+
+import { expect, test } from "vitest";
+
+import { extractGitHubUsername } from "./extract-github-username.ts";
+
+const textLink = (url: string): MessageEntity => ({
+ type: "text_link",
+ offset: 0,
+ length: 1,
+ url,
+});
+
+test("extracts a GitHub username from a profile link", () => {
+ expect(extractGitHubUsername([textLink("https://github.com/ASafaeirad")])).toBe("ASafaeirad");
+});
+
+test("skips repository and activity links before a profile link", () => {
+ const entities = [
+ textLink("https://github.com/fullstacksjs/github-bot"),
+ textLink("https://github.com/fullstacksjs/github-bot/issues/91"),
+ textLink("https://github.com/S-Kill"),
+ ];
+
+ expect(extractGitHubUsername(entities)).toBe("S-Kill");
+});
+
+test("does not extract usernames from non-GitHub or malformed links", () => {
+ const entities = [
+ textLink("https://example.com/ASafaeirad"),
+ textLink("https://github.com/-invalid"),
+ textLink("not a URL"),
+ ];
+
+ expect(extractGitHubUsername(entities)).toBeUndefined();
+});
diff --git a/src/lib/telegram/extract-github-username.ts b/src/lib/telegram/extract-github-username.ts
new file mode 100644
index 0000000..f25963c
--- /dev/null
+++ b/src/lib/telegram/extract-github-username.ts
@@ -0,0 +1,28 @@
+import type { MessageEntity } from "grammy/types";
+
+import { ghUsername } from "./schemas.ts";
+
+const isGitHubUrl = (url: URL) => url.protocol === "https:" && url.hostname.toLowerCase() === "github.com";
+
+export function extractGitHubUsername(entities: readonly MessageEntity[] | undefined) {
+ if (!entities?.length) return undefined;
+
+ for (const entity of entities) {
+ if (entity.type !== "text_link") continue;
+
+ try {
+ const url = new URL(entity.url);
+ if (!isGitHubUrl(url)) continue;
+
+ const segments = url.pathname.split("/").filter(Boolean);
+ if (segments.length !== 1) continue;
+
+ const result = ghUsername.safeParse(segments[0]);
+ if (result.success) return result.data;
+ } catch {
+ return undefined;
+ }
+ }
+
+ return undefined;
+}
diff --git a/src/lib/telegram/index.ts b/src/lib/telegram/index.ts
index c534cb8..ea38d0e 100644
--- a/src/lib/telegram/index.ts
+++ b/src/lib/telegram/index.ts
@@ -1,4 +1,5 @@
export * from "./CommandParser.ts";
export * from "./createCommand.ts";
+export * from "./extract-github-username.ts";
export * from "./render-markdown.ts";
export * as zs from "./schemas.ts";
diff --git a/src/lib/telegram/telegram-user.ts b/src/lib/telegram/telegram-user.ts
new file mode 100644
index 0000000..b36a34c
--- /dev/null
+++ b/src/lib/telegram/telegram-user.ts
@@ -0,0 +1,5 @@
+import type { User } from "grammy/types";
+
+export const toTelegramFullName = (user: User) => {
+ return [user.first_name, user.last_name].filter(Boolean).join(" ");
+};