From f4b7140f2a7b186fbba2f7d1403bd08ba477fc84 Mon Sep 17 00:00:00 2001 From: Alireza Safaierad Date: Thu, 23 Jul 2026 18:37:55 +0200 Subject: [PATCH 1/3] feat: add helpers context --- src/bot/bot.ts | 4 ++++ src/bot/middleware/helpers.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 src/bot/middleware/helpers.ts 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/middleware/helpers.ts b/src/bot/middleware/helpers.ts new file mode 100644 index 0000000..0c0dd54 --- /dev/null +++ b/src/bot/middleware/helpers.ts @@ -0,0 +1,17 @@ +import type { BotContext } from "#bot"; + +export const helpers = async (ctx: BotContext, next: () => Promise) => { + const repliedMessage = ctx.message.reply_to_message; + const isActualReply = Boolean(repliedMessage && !repliedMessage.forum_topic_created); + const isReplyToBot = ctx.isReply && repliedMessage?.from?.id === ctx.me.id; + + ctx.isReply = isActualReply; + ctx.isReplyToBot = isReplyToBot; + + return next(); +}; + +export interface HelperContext { + isReply: boolean; + isReplyToBot: boolean; +} From 2c7b869b5e2b435cf58f1c8fc616b6b67675f0fc Mon Sep 17 00:00:00 2001 From: Alireza Safaierad Date: Thu, 23 Jul 2026 18:59:17 +0200 Subject: [PATCH 2/3] refactor: add telegram user model --- src/bot/commands/public/whoami.ts | 3 ++- src/lib/telegram/telegram-user.ts | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 src/lib/telegram/telegram-user.ts 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/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(" "); +}; From 339f28d41e0fdc4d89d5345d7ae8ac0977a1300e Mon Sep 17 00:00:00 2001 From: Alireza Safaierad Date: Thu, 23 Jul 2026 18:59:45 +0200 Subject: [PATCH 3/3] feat: add bot reply support for link command --- locales/en.ftl | 8 ++ src/bot/commands/private/link.ts | 90 +++++++++++++++---- src/bot/middleware/helpers.ts | 4 +- .../telegram/extract-github-username.spec.ts | 36 ++++++++ src/lib/telegram/extract-github-username.ts | 28 ++++++ src/lib/telegram/index.ts | 1 + 6 files changed, 147 insertions(+), 20 deletions(-) create mode 100644 src/lib/telegram/extract-github-username.spec.ts create mode 100644 src/lib/telegram/extract-github-username.ts 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/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/middleware/helpers.ts b/src/bot/middleware/helpers.ts index 0c0dd54..558b403 100644 --- a/src/bot/middleware/helpers.ts +++ b/src/bot/middleware/helpers.ts @@ -1,9 +1,11 @@ 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 = ctx.isReply && repliedMessage?.from?.id === ctx.me.id; + const isReplyToBot = isActualReply && repliedMessage?.from?.id === ctx.me.id; ctx.isReply = isActualReply; ctx.isReplyToBot = isReplyToBot; 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";