Skip to content
Merged
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
8 changes: 8 additions & 0 deletions locales/en.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,22 @@ cmd_link_help =
Method 2: Provide both usernames
— <code>/link &lt;github-username&gt; &lt;telegram-username&gt;</code>

Method 3: Reply to a bot event containing a GitHub user
— <code>/link &lt;telegram-username&gt;</code>

Examples:
— Reply to user: <code>/link ASafaeirad</code>
— Direct: <code>/link ASafaeirad S_Kill</code>
— Reply to bot event: <code>/link @S_Kill</code>

cmd_link = ✅ <b>Account linked successfully!</b>

cmd_link_event = ✅ GitHub user <b>{ $ghUsername }</b> has been linked to <b>{ $tgUsername }</b>.

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 =
✍️ <code>/unlink</code> Guide:
Pass the Telegram username after the command.
Expand Down
4 changes: 4 additions & 0 deletions src/bot/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -39,6 +41,7 @@ type CommandContext<Args> = { args: Args } & { message: { text: string } };

export type BotContext<Args = Record<string, never>> = CommandContext<Args> &
Context &
HelperContext &
I18nFlavor &
LoggerContext &
MarkupContext;
Expand All @@ -65,6 +68,7 @@ export class Bot extends GrammyBot<BotContext> {
this.polling = polling === true ? {} : polling;
this.use(markup);
this.use(logger);
this.use(helpers);
this.use(this.i18n);
this.use(
limit({
Expand Down
90 changes: 71 additions & 19 deletions src/bot/commands/private/link.ts
Original file line number Diff line number Diff line change
@@ -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<z.infer<typeof schema>>) {
const repliedMessage = ctx.message.reply_to_message;
const isActualReply = repliedMessage && !repliedMessage.forum_topic_created;
type GitHubUsername = z.infer<typeof zs.ghUsername>;
type TelegramUsername = z.infer<typeof zs.tgUsername>;
type ResolvedLinkArguments =
| { error: "cmd_link_help" | "cmd_link_no_github_user" }
| { ghUsername: GitHubUsername; tgUsername: TelegramUsername | undefined };

function resolveLinkArguments(
args: z.infer<typeof schema>,
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<typeof getTelegramTarget>) {
const set: SQLiteInsertValue<typeof s.contributors> = { 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<z.infer<typeof schema>>) {
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<typeof s.contributors> = { 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)
Expand All @@ -47,11 +90,20 @@ export async function handler(ctx: BotContext<z.infer<typeof schema>>) {
set: set as SQLiteUpdateSetSource<typeof s.contributors>,
});

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,
Expand Down
3 changes: 2 additions & 1 deletion src/bot/commands/public/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions src/bot/middleware/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { BotContext } from "#bot";

export const helpers = async (ctx: BotContext, next: () => Promise<unknown>) => {
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;
}
36 changes: 36 additions & 0 deletions src/lib/telegram/extract-github-username.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
28 changes: 28 additions & 0 deletions src/lib/telegram/extract-github-username.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/lib/telegram/index.ts
Original file line number Diff line number Diff line change
@@ -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";
5 changes: 5 additions & 0 deletions src/lib/telegram/telegram-user.ts
Original file line number Diff line number Diff line change
@@ -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(" ");
};
Loading