feat: claim a license (#43)

* feat: find my license

* feat: add sponsor table and method to retrieve sponsor data

* refactor: everything

* chore: rename to license_type

* refactor: discord commands and events

---------

Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
Jason Rasmussen
2024-07-24 09:11:50 -04:00
committed by GitHub
co-authored by Zack Pollard
parent 0dc46e0c1c
commit 526af047a0
49 changed files with 4248 additions and 1003 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
node_modules
/build
/build
/dist
+1 -2
View File
@@ -5,10 +5,9 @@ node_modules
.env.*
!.env.example
*.md
*.json
/.github
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
yarn.lock
+1 -1
View File
@@ -9,7 +9,7 @@ module.exports = {
plugins: ['@typescript-eslint', 'eslint-plugin-import'],
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'import/extensions': ['error', 'always', { ts: 'never' }],
'@typescript-eslint/no-explicit-any': 'off',
},
root: true,
};
+2 -1
View File
@@ -6,5 +6,6 @@
# folders
build
dist
node_modules
coverage
coverage
+1 -2
View File
@@ -5,10 +5,9 @@ node_modules
.env.*
!.env.example
*.md
*.json
/.github
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
yarn.lock
+1 -1
View File
@@ -4,5 +4,5 @@
"printWidth": 120,
"semi": true,
"organizeImportsSkipDestructiveCodeActions": true,
"pluginSearchDirs": false
"plugins": ["prettier-plugin-organize-imports"]
}
+8 -22
View File
@@ -1,36 +1,22 @@
## build runner
FROM node:lts-alpine as build-runner
FROM node:lts-alpine AS build-runner
# Set temp directory
WORKDIR /app
# Move package.json
COPY package*.json .
# Install dependencies
RUN npm ci
# Move source files
COPY src ./src
COPY tsconfig.json .
# Build project
RUN npm run build
RUN npm prune --omit=dev
## production runner
FROM node:lts-alpine as prod-runner
FROM node:lts-alpine
ARG COMMIT
# Set work directory
RUN apk add --no-cache tini
WORKDIR /app
COPY --from=build-runner /app/node_modules /app/node_modules
COPY --from=build-runner /app/build /app/build
COPY --from=build-runner /app/dist /app/dist
COPY package*.json .
ARG COMMIT
ENV COMMIT_SHA=${COMMIT}
# Start bot
CMD [ "npm", "run", "start" ]
USER node
ENTRYPOINT ["/sbin/tini", "--", "/bin/sh"]
CMD ["-c", "node dist/main.js"]
+2917 -163
View File
File diff suppressed because it is too large Load Diff
+31 -9
View File
@@ -3,40 +3,57 @@
"version": "1.0.0",
"private": true,
"license": "MIT",
"main": "build/main.js",
"type": "module",
"main": "dist/main.js",
"scripts": {
"build": "tsc",
"build:changelog": "npx @discordx/changelog --src src",
"dev": "ts-node src/main.ts",
"start": "node build/main.js",
"watch": "nodemon --exec ts-node src/main.ts",
"build": "nest build",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"lint": "eslint . --max-warnings 0",
"start": "npm run start:dev",
"nest": "nest",
"start:dev": "nest start --watch --",
"start:debug": "nest start --debug 0.0.0.0:9230 --watch --",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"check": "tsc --noEmit",
"check:code": "npm run format && npm run lint && npm run check",
"check:all": "npm run check:code && npm run test:cov",
"test": "vitest",
"test:watch": "vitest --watch",
"test:cov": "vitest --coverage"
},
"dependencies": {
"@discordx/importer": "^1.2.3",
"@nestjs/common": "^10.3.10",
"@nestjs/core": "^10.3.10",
"@nestjs/platform-express": "^10.3.10",
"@nestjs/schedule": "^4.1.0",
"@nestjs/swagger": "^7.4.0",
"@octokit/request-error": "^5.0.1",
"@octokit/rest": "^20.0.2",
"@octokit/types": "^13.4.1",
"@types/lodash": "^4.14.200",
"@types/luxon": "^3.3.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cors": "^2.8.5",
"cron": "^3.0.0",
"discord.js": "^14.14.0",
"discordx": "^11.7.6",
"dotenv": "^16.4.5",
"eslint-plugin-import": "^2.29.1",
"express": "^4.19.2",
"kysely": "^0.27.4",
"lodash": "^4.17.21",
"luxon": "^3.4.3",
"openid-client": "^5.6.5",
"pg": "^8.12.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.2",
"@nestjs/schematics": "^10.1.2",
"@nestjs/testing": "^10.3.10",
"@swc/core": "^1.7.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.5.9",
"@types/pg": "^8.11.6",
@@ -46,12 +63,17 @@
"eslint": "^8.50.0",
"nodemon": "^3.0.1",
"prettier": "^3.0.3",
"prettier-plugin-organize-imports": "^4.0.0",
"ts-node": "^10.9.1",
"typescript": "5.2.2",
"typescript": "^5.2.2",
"unplugin-swc": "^1.5.1",
"vitest": "^1.5.2"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=7.0.0"
},
"volta": {
"node": "20.15.1"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base", "docker:pinDigests"],
"minimumReleaseAge": "5 days",
"packageRules": [
{
"groupName": "dependencies",
"matchUpdateTypes": ["minor", "patch"],
"excludePackageNames": ["node", "@types/node"],
"schedule": "on tuesday"
},
{
"matchFileNames": [".github/**"],
"groupName": "github-actions",
"schedule": "on tuesday"
},
{
"matchDatasources": ["npm"],
"rangeStrategy": "bump",
"groupName": "node",
"versioning": "node",
"matchPackageNames": ["node", "@types/node"],
"schedule": "on tuesday"
},
{
"groupName": "node",
"matchDatasources": ["docker"],
"matchPackageNames": ["node"],
"versionCompatibility": "^(?<version>[^-]+)(?<compatibility>-.*)?$",
"versioning": "node",
"schedule": "on tuesday"
}
],
"labels": ["dependencies", "renovate"]
}
+25
View File
@@ -0,0 +1,25 @@
import { Module, OnModuleInit, ValidationPipe } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { controllers } from 'src/controllers';
import { BotCommands } from 'src/discord/commands';
import { BotEvents } from 'src/discord/events';
import { providers } from 'src/repositories';
import { services } from 'src/services';
import { DatabaseService } from 'src/services/database.service';
const middleware = [{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) }];
const discord = [BotCommands, BotEvents];
@Module({
imports: [ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...services, ...providers, ...middleware, ...discord],
})
export class AppModule implements OnModuleInit {
constructor(private databaseService: DatabaseService) {}
async onModuleInit() {
await this.databaseService.runMigrations();
}
}
-147
View File
@@ -1,147 +0,0 @@
import {
ApplicationCommandOptionType,
MessageFlags,
type CommandInteraction,
AutocompleteInteraction,
ThreadChannel,
} from 'discord.js';
import { Discord, Slash, SlashChoice, SlashOption } from 'discordx';
import { Constants } from '../constants.js';
import { DateTime } from 'luxon';
import { getForksMessage, getStarsMessage, handleSearchAutocompletion } from '../service.js';
import { GithubRepository } from '../repositories/github.repository.js';
const linkCommands: Record<string, string> = {
'reverse proxy': Constants.Urls.Docs.ReverseProxy,
database: Constants.Urls.Docs.Queries,
upgrade: Constants.Urls.Docs.Upgrade,
libraries: Constants.Urls.Docs.Libraries,
'xmp sidecar': Constants.Urls.Docs.Sidecar,
docker: Constants.Urls.Docs.Docker,
backup: Constants.Urls.Docs.Backup,
github: Constants.Urls.ImmichRepo,
cli: Constants.Urls.Docs.CLI,
'google-takeout': Constants.Urls.GoogleTakeOut,
faq: Constants.Urls.Docs.FAQ,
};
export const HELP_TEXTS = {
'docker logs': `View container logs by running \`docker compose logs\`. For further information refer to ${Constants.Urls.Docs.Docker}`,
'help ticket': `Please open a <#${Constants.Channels.HelpDesk}> ticket with more information and we can help you troubleshoot the issue.`,
'reverse proxy': `This sounds like it could be a reverse proxy issue. Here's a link to the relevant documentation page: ${Constants.Urls.Docs.ReverseProxy}.`,
'feature request': `For ideas or features you'd like Immich to have, feel free to [open a feature request in the Github discussions](${Constants.Urls.FeatureRequest}). However, please make sure to search for similar requests first to avoid duplicates.`,
};
@Discord()
export class Commands {
constructor(private repository: GithubRepository = new GithubRepository()) {}
@Slash({ name: 'link', description: 'Links to Immich pages' })
handleLink(
@SlashChoice(...Object.keys(linkCommands))
@SlashOption({
description: 'Which docs do you need?',
name: 'type',
required: true,
type: ApplicationCommandOptionType.String,
})
name: string,
@SlashOption({
description: 'Text that will be prepended before the link',
name: 'text',
required: false,
type: ApplicationCommandOptionType.String,
})
message: string | null,
interaction: CommandInteraction,
) {
return interaction.reply({
content: message ? `${message}: ${linkCommands[name]}` : linkCommands[name],
flags: [MessageFlags.SuppressEmbeds],
});
}
@Slash({ name: 'messages', description: 'Text blocks for reoccurring questions' })
handleMessages(
@SlashChoice(...Object.keys(HELP_TEXTS))
@SlashOption({
description: 'Which message do you need',
name: 'type',
required: true,
type: ApplicationCommandOptionType.String,
})
name: keyof typeof HELP_TEXTS,
interaction: CommandInteraction,
) {
return interaction.reply({
content: HELP_TEXTS[name],
flags: [MessageFlags.SuppressEmbeds],
});
}
@Slash({ name: 'stars', description: 'Immich stars' })
async handleStars(interaction: CommandInteraction) {
return interaction.reply(await getStarsMessage(this.repository, interaction.channelId));
}
@Slash({ name: 'forks', description: 'Immich forks' })
async handleForks(interaction: CommandInteraction) {
return interaction.reply(await getForksMessage(this.repository, interaction.channelId));
}
@Slash({ name: 'age', description: 'Immich age' })
handleAge(interaction: CommandInteraction) {
const age = DateTime.now()
.diff(DateTime.fromObject({ year: 2022, month: 2, day: 3, hour: 15, minute: 56 }, { zone: 'UTC' }), [
'years',
'months',
'days',
'hours',
'minutes',
'seconds',
])
.toHuman({ listStyle: 'long', maximumFractionDigits: 0 });
return interaction.reply(`Immich is ${age} old. ${Constants.Icons.Immich}`);
}
@Slash({ name: 'search', description: 'Search for PRs and Issues by title' })
async handleSearch(
@SlashOption({
description: 'Query that applies to title',
name: 'query',
required: true,
type: ApplicationCommandOptionType.String,
autocomplete: async (interaction: AutocompleteInteraction) =>
interaction.respond(
await handleSearchAutocompletion(new GithubRepository(), interaction.options.getFocused(true).value),
),
})
id: string,
interaction: CommandInteraction,
) {
const content = await this.repository.getIssueOrPr(id);
return interaction.reply({ content, flags: [MessageFlags.SuppressEmbeds] });
}
@Slash({ name: 'tags', description: 'Returns the currently set tags' })
async handleGetTags(interaction: CommandInteraction) {
const members = interaction.guild?.members.cache;
if (!members?.get(interaction.user.id)?.roles.cache.has(Constants.Roles.Contributor)) {
return;
}
const channel = interaction.channel;
if (channel instanceof ThreadChannel) {
await interaction.reply(channel.appliedTags.join(', '));
}
}
@Slash({ name: 'release-notes', description: 'Release notes' })
async handleRTFM(interaction: CommandInteraction) {
await interaction.reply({
content: `Please make sure you have read and followed the release notes: ${Constants.Urls.Release}`,
flags: [MessageFlags.SuppressEmbeds],
});
}
}
+36
View File
@@ -0,0 +1,36 @@
import dotenv from 'dotenv';
export const getConfig = () => {
dotenv.config();
const clientId = process.env.IMMICH_GITHUB_CLIENT_ID;
const clientSecret = process.env.IMMICH_GITHUB_CLIENT_SECRET;
const databaseUri = process.env.uri;
const botToken = process.env.BOT_TOKEN;
const githubWebhookSlug = process.env.GITHUB_STATUS_SLUG;
const stripeWebhookSlug = process.env.STRIPE_PAYMENT_SLUG;
const commitSha = process.env.COMMIT_SHA;
if (!clientId || !clientSecret || !databaseUri || !botToken) {
console.log({ clientId, clientSecret, databaseUri, botToken });
throw new Error('Missing required environment variables');
}
return {
commitSha: commitSha || 'dev',
bot: {
token: botToken,
},
database: {
uri: databaseUri,
},
github: {
clientId,
clientSecret,
},
slugs: {
githubWebhook: githubWebhookSlug,
stripeWebhook: stripeWebhookSlug,
},
};
};
+26 -12
View File
@@ -1,3 +1,5 @@
import { DiscordChannel } from 'src/interfaces/discord.interface';
export const IMMICH_REPOSITORY_BASE_OPTIONS = { owner: 'immich-app', repo: 'immich' };
const docs = {
@@ -43,26 +45,38 @@ const roles = {
Contributor: '980972470964215870',
};
const channels = {
HelpDesk: '1049703391762321418',
General: '994044917355663450',
BotSpam: '1159083520027787307',
GithubStatus: '1240662502912692236',
Stripe: '1263492970691297300',
};
const misc = {
ImmichBirthdayCron: '36 4 3 2 *',
const cron = {
ImmichBirthday: '36 4 3 2 *',
};
export const Constants = {
Misc: misc,
Urls: {
Docs: docs,
...urls,
},
Icons: icons,
Channels: channels,
Roles: roles,
Tags: tags,
Cron: cron,
};
export const linkCommands: Record<string, string> = {
'reverse proxy': Constants.Urls.Docs.ReverseProxy,
database: Constants.Urls.Docs.Queries,
upgrade: Constants.Urls.Docs.Upgrade,
libraries: Constants.Urls.Docs.Libraries,
'xmp sidecar': Constants.Urls.Docs.Sidecar,
docker: Constants.Urls.Docs.Docker,
backup: Constants.Urls.Docs.Backup,
github: Constants.Urls.ImmichRepo,
cli: Constants.Urls.Docs.CLI,
'google-takeout': Constants.Urls.GoogleTakeOut,
faq: Constants.Urls.Docs.FAQ,
};
export const HELP_TEXTS = {
'docker logs': `View container logs by running \`docker compose logs\`. For further information refer to ${Constants.Urls.Docs.Docker}`,
'help ticket': `Please open a <#${DiscordChannel.HelpDesk}> ticket with more information and we can help you troubleshoot the issue.`,
'reverse proxy': `This sounds like it could be a reverse proxy issue. Here's a link to the relevant documentation page: ${Constants.Urls.Docs.ReverseProxy}.`,
'feature request': `For ideas or features you'd like Immich to have, feel free to [open a feature request in the Github discussions](${Constants.Urls.FeatureRequest}). However, please make sure to search for similar requests first to avoid duplicates.`,
};
+4
View File
@@ -0,0 +1,4 @@
import { OAuthController } from 'src/controllers/oauth.controller';
import { WebhookController } from 'src/controllers/webhook.controller';
export const controllers = [OAuthController, WebhookController];
+19
View File
@@ -0,0 +1,19 @@
import { Body, Controller, Injectable, Post } from '@nestjs/common';
import { OAuthAuthorizeDto, OAuthCallbackDto } from 'src/dtos/oauth.dto';
import { OAuthService } from 'src/services/oauth.service';
@Injectable()
@Controller('oauth')
export class OAuthController {
constructor(private service: OAuthService) {}
@Post('authorize')
authorize(@Body() dto: OAuthAuthorizeDto) {
return this.service.authorize(dto);
}
@Post('callback')
callback(@Body() dto: OAuthCallbackDto) {
return this.service.callback(dto);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Body, Controller, Injectable, Param, Post } from '@nestjs/common';
import { GithubStatusComponent, GithubStatusIncident, StripeBase } from 'src/dtos/webhook.dto';
import { WebhookService } from 'src/services/webhook.service';
@Injectable()
@Controller('webhooks')
export class WebhookController {
constructor(private service: WebhookService) {}
@Post('github-status/:slug')
async onGithubStatus(@Body() dto: GithubStatusIncident | GithubStatusComponent, @Param('slug') slug: string) {
await this.service.onGithubStatus(dto, slug);
}
@Post('stripe-payments/:slug')
async onStripePayment(@Body() dto: StripeBase, @Param('slug') slug: string) {
await this.service.onStripePayment(dto, slug);
}
}
@@ -1,116 +0,0 @@
import express from 'express';
import { bot } from '../../main.js';
import { Constants } from '../../constants.js';
import { EmbedBuilder, TextChannel } from 'discord.js';
const app = express.Router();
type GithubStatusBase = {
meta: { unsubscribe: string; documentation: string };
page: { id: string; status_indicator: string; status_description: string };
};
type GithubStatusComponent = GithubStatusBase & {
component_update: { createdAt: string; new_status: string; old_status: string; id: string; component_id: string };
component: { created_at: string; id: string; name: string; status: string };
};
type GithubStatusIncident = GithubStatusBase & {
incident: {
name: string;
backfilled: boolean;
created_at: string;
impact: string;
impact_override: unknown;
monitoring_at: string;
postmortem_body: unknown;
postmortem_body_last_updated_at: string;
postmortem_ignored: boolean;
postmortem_notified_subscribers: boolean;
postmortem_notified_twitter: boolean;
postmortem_published_at: string;
resolved_at: string;
scheduled_auto_transition: boolean;
scheduled_for: string;
scheduled_remind_prior: boolean;
scheduled_reminded_at: string;
scheduled_until: string;
shortlink: string;
status: string;
updated_at: string;
id: string;
organization_id: string;
incident_updates: [
{
body: string;
created_at: string;
display_at: string;
status: string;
twitter_updated_at: string;
updated_at: string;
wants_twitter_update: boolean;
id: string;
incident_id: string;
},
];
};
};
// const isGithubComponentUpdate = (
// payload: GithubStatusComponent | GithubStatusIncident,
// ): payload is GithubStatusComponent => {
// if ((payload as GithubStatusComponent).component) {
// return true;
// }
// return false;
// };
const isGithubIncidentUpdate = (
payload: GithubStatusComponent | GithubStatusIncident,
): payload is GithubStatusIncident => {
if ((payload as GithubStatusIncident).incident) {
return true;
}
return false;
};
app.post('/github-status/:slug', async (req, res) => {
if (req.params.slug !== process.env.GITHUB_STATUS_SLUG) {
res.status(401).send();
return;
}
const body = req.body;
console.log(JSON.stringify(body));
if (isGithubIncidentUpdate(body)) {
const channel = (await bot.channels.fetch(Constants.Channels.GithubStatus)) as TextChannel;
const embed = new EmbedBuilder({
title: body.page.status_description,
author: { name: 'GitHub Status', url: 'https://githubstatus.com' },
url: body.incident.shortlink,
fields: [{ name: body.incident.name, value: body.incident.incident_updates[0].body.replaceAll('<br />', '\n') }],
});
if (body.incident.status === 'resolved') {
embed.setColor('Green');
} else {
switch (body.incident.impact) {
case 'minor':
embed.setColor('Orange');
break;
case 'major':
embed.setColor('Red');
break;
default:
embed.setColor('Grey');
}
}
await channel.send({ embeds: [embed] });
}
res.status(200).send();
});
export const githubWebhooks = app;
@@ -1,131 +0,0 @@
import express from 'express';
import { bot } from '../../main.js';
import { Constants } from '../../constants.js';
import { Colors, EmbedBuilder, TextChannel } from 'discord.js';
import { db } from '../../db.js';
import { logError } from '../../util.js';
const app = express.Router();
type StripeBase<T = unknown> = {
id: string;
object: string;
type: string;
data: {
object: {
id: string;
object: string;
} & T;
};
};
type PaymentIntent = {
amount: number;
currency: string;
created: number;
description: string;
status: string;
receipt_email: string;
livemode: boolean;
};
const isPaymentEvent = (payload: StripeBase): payload is StripeBase<PaymentIntent> =>
payload.data.object.object === 'payment_intent';
const isImmichProduct = (payload: StripeBase<PaymentIntent>) =>
['immich-server', 'immich-client'].includes(payload.data.object.description);
async function writePaymentToDb(event: StripeBase<PaymentIntent>) {
const { id, description, amount, currency, status, created, livemode } = event.data.object;
try {
await db
.insertInto('payment')
.values({
event_id: event.id,
id,
amount,
currency,
status,
description,
created,
livemode,
data: JSON.stringify(event),
})
.execute();
} catch (error) {
await logError('Failed to insert payment into database', error, bot);
}
}
async function getTotalLicenseCount() {
try {
const result = await db
.selectFrom('payment')
.select([(b) => b.fn.count<number>('description').as('product_count'), 'description'])
.where('livemode', '=', true)
.where('status', '=', 'succeeded')
.groupBy('description')
.execute();
return {
server: result.find((r) => r.description === 'immich-server')?.product_count || 0,
client: result.find((r) => r.description === 'immich-client')?.product_count || 0,
};
} catch (error) {
await logError('Failed to insert payment into database', error, bot);
return {
server: 0,
client: 0,
};
}
}
app.post('/stripe-payments/:slug', async (req, res) => {
if (req.params.slug !== process.env.STRIPE_PAYMENT_SLUG) {
res.status(401).send();
return;
}
res.status(204).send();
if (!isPaymentEvent(req.body) || !isImmichProduct(req.body)) {
return;
}
const { id, description, amount, currency, status, livemode } = req.body.data.object;
await writePaymentToDb(req.body);
if (status !== 'succeeded') {
return;
}
const { server, client } = await getTotalLicenseCount();
const licenseType = description.split('-')[1];
const channel = (await bot.channels.fetch(Constants.Channels.Stripe)) as TextChannel;
await channel.send({
embeds: [
new EmbedBuilder()
.setTitle(`${livemode ? '' : 'TEST PAYMENT - '}Immich ${licenseType} license purchased`)
.setURL(`https://dashboard.stripe.com/${livemode ? '' : 'test/'}payments/${id}`)
.setAuthor({ name: 'Stripe Payments', url: 'https://stripe.com' })
.setDescription(`Price: ${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`)
.setColor(livemode ? Colors.Green : Colors.Yellow)
.setFields([
{
name: 'Server licenses',
value: `$${(server * 99.99).toFixed(2)} - ${server} licenses`,
inline: true,
},
{
name: 'Client licenses',
value: `$${(client * 24.99).toFixed(2)} - ${client} licenses`,
inline: true,
},
]),
],
});
});
export const stripeWebhooks = app;
-31
View File
@@ -1,31 +0,0 @@
import { Insertable, JSONColumnType, Kysely, PostgresDialect, Selectable } from 'kysely';
import pg from 'pg';
export interface Database {
payment: PaymentTable;
}
export interface PaymentTable {
event_id: string;
id: string;
amount: number;
currency: string;
status: string;
description: string;
created: number;
livemode: boolean;
data: JSONColumnType<object>;
}
export type Payment = Selectable<PaymentTable>;
export type NewPayment = Insertable<PaymentTable>;
const dialect = new PostgresDialect({
pool: new pg.Pool({
connectionString: process.env.uri,
}),
});
export const db = new Kysely<Database>({
dialect,
});
+115
View File
@@ -0,0 +1,115 @@
import { Injectable } from '@nestjs/common';
import {
ApplicationCommandOptionType,
AutocompleteInteraction,
MessageFlags,
ThreadChannel,
type CommandInteraction,
} from 'discord.js';
import { Discord, Slash, SlashChoice, SlashOption } from 'discordx';
import { Constants, HELP_TEXTS, linkCommands } from 'src/constants';
import { DiscordService } from 'src/services/discord.service';
@Discord()
@Injectable()
export class BotCommands {
constructor(private service: DiscordService) {}
@Slash({ name: 'age', description: 'Immich age' })
handleAge(interaction: CommandInteraction) {
const message = this.service.getAge();
return interaction.reply(message);
}
@Slash({ name: 'forks', description: 'Immich forks' })
async handleForks(interaction: CommandInteraction) {
const message = await this.service.getForksMessage(interaction.channelId);
return interaction.reply(message);
}
@Slash({ name: 'release-notes', description: 'Release notes' })
handleReleaseNotes(interaction: CommandInteraction) {
const message = this.service.getReleaseNotes();
return interaction.reply({ content: message, flags: [MessageFlags.SuppressEmbeds] });
}
@Slash({ name: 'stars', description: 'Immich stars' })
async handleStars(interaction: CommandInteraction) {
const message = await this.service.getStarsMessage(interaction.channelId);
return interaction.reply(message);
}
@Slash({ name: 'link', description: 'Links to Immich pages' })
handleLink(
@SlashChoice(...Object.keys(linkCommands))
@SlashOption({
description: 'Which docs do you need?',
name: 'type',
required: true,
type: ApplicationCommandOptionType.String,
})
name: string,
@SlashOption({
description: 'Text that will be prepended before the link',
name: 'text',
required: false,
type: ApplicationCommandOptionType.String,
})
message: string | null,
interaction: CommandInteraction,
) {
const content = this.service.getLink(name, message);
return interaction.reply({ content, flags: [MessageFlags.SuppressEmbeds] });
}
@Slash({ name: 'tags', description: 'Returns the currently set tags' })
async handleGetTags(interaction: CommandInteraction) {
const members = interaction.guild?.members.cache;
if (!members?.get(interaction.user.id)?.roles.cache.has(Constants.Roles.Contributor)) {
return;
}
const channel = interaction.channel;
if (channel instanceof ThreadChannel) {
await interaction.reply(channel.appliedTags.join(', '));
}
}
@Slash({ name: 'messages', description: 'Text blocks for reoccurring questions' })
handleMessages(
@SlashChoice(...Object.keys(HELP_TEXTS))
@SlashOption({
description: 'Which message do you need',
name: 'type',
required: true,
type: ApplicationCommandOptionType.String,
})
name: keyof typeof HELP_TEXTS,
interaction: CommandInteraction,
) {
const message = this.service.getHelpMessage(name);
return interaction.reply({ content: message, flags: [MessageFlags.SuppressEmbeds] });
}
@Slash({ name: 'search', description: 'Search for PRs and Issues by title' })
async handleSearch(
@SlashOption({
description: 'Query that applies to title',
name: 'query',
required: true,
type: ApplicationCommandOptionType.String,
autocomplete: true,
})
id: string,
interaction: CommandInteraction | AutocompleteInteraction,
) {
if (interaction.isAutocomplete()) {
const value = interaction.options.getFocused(true).value;
const message = await this.service.handleSearchAutocompletion(value);
return interaction.respond(message);
}
const content = await this.service.getPrOrIssue(id);
return interaction.reply({ content, flags: [MessageFlags.SuppressEmbeds] });
}
}
@@ -1,18 +1,19 @@
import { Constants } from '../constants.js';
import { Injectable } from '@nestjs/common';
import { Message, MessageFlags, PartialMessage } from 'discord.js';
import { ArgsOf, Discord, On } from 'discordx';
import _ from 'lodash';
import { GithubRepository } from '../repositories/github.repository.js';
import { handleGithubReferences } from '../service.js';
import { Constants } from 'src/constants';
import { DiscordService } from 'src/services/discord.service';
const PREVIEW_BLACKLIST = [Constants.Urls.Immich, Constants.Urls.GitHub];
@Discord()
export class MessageEvents {
constructor(private githubRepository: GithubRepository = new GithubRepository()) {}
@Injectable()
export class BotEvents {
constructor(private service: DiscordService) {}
@On({ event: 'messageCreate' })
async handleMessageCreate([message]: ArgsOf<'messageCreate'>) {
async onMessageCreate([message]: ArgsOf<'messageCreate'>) {
if (message.author.bot) {
return;
}
@@ -21,7 +22,7 @@ export class MessageEvents {
}
@On({ event: 'messageUpdate' })
async handleMessageUpdate([oldMessage, newMessage]: ArgsOf<'messageUpdate'>) {
async onMessageUpdate([oldMessage, newMessage]: ArgsOf<'messageUpdate'>) {
if (oldMessage.author?.bot) {
return;
}
@@ -32,7 +33,7 @@ export class MessageEvents {
}
private async handleGithubShortLinks(message: Message<boolean>) {
const links = await handleGithubReferences(this.githubRepository, message.content);
const links = await this.service.handleGithubReferences(message.content);
if (links.length !== 0) {
await message.reply({ content: links.join('\n'), flags: [MessageFlags.SuppressEmbeds] });
}
@@ -11,8 +11,14 @@ import {
ThreadChannel,
} from 'discord.js';
import { ArgsOf, ButtonComponent, Discord, On, Slash, SlashChoice, SlashOption } from 'discordx';
import { Constants } from '../../constants.js';
import { getComposeUploadModal, getEnvUploadModal, getHelpDeskWelcomeMessage, getLogsUploadModel } from './util.js';
import { Constants } from 'src/constants';
import {
getComposeUploadModal,
getEnvUploadModal,
getHelpDeskWelcomeMessage,
getLogsUploadModel,
} from 'src/discord/util';
import { DiscordChannel } from 'src/interfaces/discord.interface';
const submitButton = new ButtonBuilder({
customId: 'submit',
@@ -67,7 +73,7 @@ export class HelpTicket {
return;
}
if (channel.parentId !== Constants.Channels.HelpDesk) {
if (channel.parentId !== DiscordChannel.HelpDesk) {
return;
}
@@ -90,7 +96,7 @@ export class HelpTicket {
@On({ event: 'threadCreate' })
async handleThreadCreate([thread]: ArgsOf<'threadCreate'>) {
if (thread.parentId !== Constants.Channels.HelpDesk) {
if (thread.parentId !== DiscordChannel.HelpDesk) {
return;
}
@@ -106,7 +112,7 @@ export class HelpTicket {
@Slash({ name: 'helpdesk', description: 'Trigger help desk message' })
async handleHelpDeskCommand(interaction: CommandInteraction) {
if (!interaction.channel?.isThread() || interaction.channel.parentId !== Constants.Channels.HelpDesk) {
if (!interaction.channel?.isThread() || interaction.channel.parentId !== DiscordChannel.HelpDesk) {
await interaction.reply({
content: 'This command may only be executed in help desk threads',
flags: [MessageFlags.Ephemeral],
@@ -127,11 +133,11 @@ export class HelpTicket {
async handleTicketOpen(interaction: BaseInteraction) {
const channel = interaction.channel;
if (!(channel instanceof ThreadChannel) || channel.parentId !== Constants.Channels.HelpDesk) {
if (!(channel instanceof ThreadChannel) || channel.parentId !== DiscordChannel.HelpDesk) {
if (interaction instanceof CommandInteraction) {
return interaction?.reply({
ephemeral: true,
content: `This command can only be invoked in <#${Constants.Channels.HelpDesk}> tickets.`,
content: `This command can only be invoked in <#${DiscordChannel.HelpDesk}> tickets.`,
});
}
return;
@@ -144,10 +150,10 @@ export class HelpTicket {
@Slash({ name: 'close', description: 'Closes the ticket. Can be re-opened if need be' })
async handleTicketClose(interaction: CommandInteraction) {
const channel = interaction.channel;
if (!(channel instanceof ThreadChannel) || channel.parentId !== Constants.Channels.HelpDesk) {
if (!(channel instanceof ThreadChannel) || channel.parentId !== DiscordChannel.HelpDesk) {
return interaction.reply({
ephemeral: true,
content: `This command can only be invoked in <#${Constants.Channels.HelpDesk}> tickets.`,
content: `This command can only be invoked in <#${DiscordChannel.HelpDesk}> tickets.`,
});
}
@@ -177,10 +183,10 @@ export class HelpTicket {
interaction: CommandInteraction,
) {
const channel = interaction.channel;
if (!(channel instanceof ThreadChannel) || channel.parentId !== Constants.Channels.HelpDesk) {
if (!(channel instanceof ThreadChannel) || channel.parentId !== DiscordChannel.HelpDesk) {
return interaction.reply({
ephemeral: true,
content: `This command can only be invoked in <#${Constants.Channels.HelpDesk}> tickets.`,
content: `This command can only be invoked in <#${DiscordChannel.HelpDesk}> tickets.`,
});
}
const members = interaction.guild?.members.cache;
+48
View File
@@ -0,0 +1,48 @@
import { MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Discord, ModalComponent } from 'discordx';
import { COMPOSE_MODAL_ID, ENV_MODAL_ID, LOGS_MODAL_ID } from 'src/discord/util';
@Discord()
export class ComposeModal {
@ModalComponent({ id: COMPOSE_MODAL_ID })
async handleComposeModal(interaction: ModalSubmitInteraction): Promise<void> {
const compose = interaction.fields.getTextInputValue('compose');
await interaction.channel?.send({
content: `${interaction.user} uploaded`,
files: [{ attachment: Buffer.from(compose), name: 'docker-compose.yml' }],
flags: [MessageFlags.SuppressNotifications],
});
await interaction.deferUpdate();
}
}
@Discord()
export class EnvModal {
@ModalComponent({ id: ENV_MODAL_ID })
async handleEnvModal(interaction: ModalSubmitInteraction): Promise<void> {
const env = interaction.fields.getTextInputValue('env');
await interaction.channel?.send({
content: `${interaction.user} uploaded`,
files: [{ attachment: Buffer.from(env), name: 'env.txt' }],
flags: [MessageFlags.SuppressNotifications],
});
await interaction.deferUpdate();
}
}
@Discord()
export class LogsModal {
@ModalComponent({ id: LOGS_MODAL_ID })
async handleLogsModal(interaction: ModalSubmitInteraction): Promise<void> {
const [logsSource, logs] = ['logsSource', 'logs'].map((id) => interaction.fields.getTextInputValue(id));
await interaction.channel?.send({
content: `${interaction.user} uploaded`,
files: [{ attachment: Buffer.from(logs), name: `${logsSource}.txt` }],
flags: [MessageFlags.SuppressNotifications],
});
await interaction.deferUpdate();
}
}
@@ -1,5 +1,5 @@
import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js';
import { Constants } from '../../constants.js';
import { Constants } from 'src/constants';
export const LOGS_MODAL_ID = 'logsModal';
export const COMPOSE_MODAL_ID = 'composeModal';
+13
View File
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class OAuthAuthorizeDto {
@IsNotEmpty()
@IsString()
redirectUri!: string;
}
export class OAuthCallbackDto {
@IsNotEmpty()
@IsString()
url!: string;
}
+72
View File
@@ -0,0 +1,72 @@
export type GithubStatusBase = {
meta: { unsubscribe: string; documentation: string };
page: { id: string; status_indicator: string; status_description: string };
};
export type GithubStatusComponent = GithubStatusBase & {
component_update: { createdAt: string; new_status: string; old_status: string; id: string; component_id: string };
component: { created_at: string; id: string; name: string; status: string };
};
export type GithubStatusIncident = GithubStatusBase & {
incident: {
name: string;
backfilled: boolean;
created_at: string;
impact: string;
impact_override: unknown;
monitoring_at: string;
postmortem_body: unknown;
postmortem_body_last_updated_at: string;
postmortem_ignored: boolean;
postmortem_notified_subscribers: boolean;
postmortem_notified_twitter: boolean;
postmortem_published_at: string;
resolved_at: string;
scheduled_auto_transition: boolean;
scheduled_for: string;
scheduled_remind_prior: boolean;
scheduled_reminded_at: string;
scheduled_until: string;
shortlink: string;
status: string;
updated_at: string;
id: string;
organization_id: string;
incident_updates: [
{
body: string;
created_at: string;
display_at: string;
status: string;
twitter_updated_at: string;
updated_at: string;
wants_twitter_update: boolean;
id: string;
incident_id: string;
},
];
};
};
export type StripeBase<T = unknown> = {
id: string;
object: string;
type: string;
data: {
object: {
id: string;
object: string;
} & T;
};
};
export type PaymentIntent = {
amount: number;
currency: string;
created: number;
description: string;
status: string;
receipt_email: string;
livemode: boolean;
};
-18
View File
@@ -1,18 +0,0 @@
import { MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Discord, ModalComponent } from 'discordx';
import { COMPOSE_MODAL_ID } from './util.js';
@Discord()
export class ComposeModal {
@ModalComponent({ id: COMPOSE_MODAL_ID })
async handleComposeModal(interaction: ModalSubmitInteraction): Promise<void> {
const compose = interaction.fields.getTextInputValue('compose');
await interaction.channel?.send({
content: `${interaction.user} uploaded`,
files: [{ attachment: Buffer.from(compose), name: 'docker-compose.yml' }],
flags: [MessageFlags.SuppressNotifications],
});
await interaction.deferUpdate();
}
}
-18
View File
@@ -1,18 +0,0 @@
import { MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Discord, ModalComponent } from 'discordx';
import { ENV_MODAL_ID } from './util.js';
@Discord()
export class EnvModal {
@ModalComponent({ id: ENV_MODAL_ID })
async handleEnvModal(interaction: ModalSubmitInteraction): Promise<void> {
const env = interaction.fields.getTextInputValue('env');
await interaction.channel?.send({
content: `${interaction.user} uploaded`,
files: [{ attachment: Buffer.from(env), name: 'env.txt' }],
flags: [MessageFlags.SuppressNotifications],
});
await interaction.deferUpdate();
}
}
-18
View File
@@ -1,18 +0,0 @@
import { MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Discord, ModalComponent } from 'discordx';
import { LOGS_MODAL_ID } from './util.js';
@Discord()
export class LogsModal {
@ModalComponent({ id: LOGS_MODAL_ID })
async handleLogsModal(interaction: ModalSubmitInteraction): Promise<void> {
const [logsSource, logs] = ['logsSource', 'logs'].map((id) => interaction.fields.getTextInputValue(id));
await interaction.channel?.send({
content: `${interaction.user} uploaded`,
files: [{ attachment: Buffer.from(logs), name: `${logsSource}.txt` }],
flags: [MessageFlags.SuppressNotifications],
});
await interaction.deferUpdate();
}
}
+53
View File
@@ -0,0 +1,53 @@
import { Insertable, JSONColumnType, Selectable, Updateable } from 'kysely';
export const IDatabaseRepository = 'IDatabaseRepository';
export interface PaymentTable {
event_id: string;
id: string;
amount: number;
currency: string;
status: string;
description: string;
created: number;
livemode: boolean;
data: JSONColumnType<object>;
}
export type Payment = Selectable<PaymentTable>;
export type NewPayment = Insertable<PaymentTable>;
export enum LicenseType {
Server = 'immich-server',
Client = 'immich-client',
}
export interface SponsorTable {
username: string;
email: string;
total: number;
claimed: boolean;
license_type: 'client' | 'server';
licenses: { license: string; activation: string }[];
}
export interface License {
type: LicenseType;
licenseKey: string;
activationKey: string;
}
export type Sponsor = Selectable<SponsorTable>;
export type UpdateSponsor = Updateable<SponsorTable>;
export interface Database {
payment: PaymentTable;
sponsor: SponsorTable;
}
export interface IDatabaseRepository {
runMigrations(): Promise<void>;
createPayment(entitY: NewPayment): Promise<void>;
getTotalLicenseCount(): Promise<{ server: number; client: number }>;
getSponsorLicenses(githubUsername: string): Promise<License[]>;
}
+26
View File
@@ -0,0 +1,26 @@
import { MessageCreateOptions } from 'discord.js';
export const IDiscordInterface = 'IDiscordInterface';
export enum DiscordChannel {
HelpDesk = '1049703391762321418',
General = '994044917355663450',
BotSpam = '1159083520027787307',
GithubStatus = '1240662502912692236',
Stripe = '1263492970691297300',
}
export enum DiscordEvents {
Ready = 'ready',
MessageCreate = 'messageCreate',
InteractionCreate = 'interactionCreate',
Error = 'error',
}
export interface IDiscordInterface {
once(event: 'ready', listener: () => void): this;
on(event: 'error', listener: (error: Error) => void): this;
login(token: string): Promise<void>;
initApplicationCommands(): Promise<void>;
sendMessage(channel: DiscordChannel, message: string | MessageCreateOptions): Promise<void>;
}
+27
View File
@@ -0,0 +1,27 @@
export const IGithubInterface = 'IGithubRepository';
export interface SearchOptions {
query: string;
per_page?: number;
page: number;
sort: 'updated';
order: 'desc' | 'asc';
}
export interface SearchResult {
total_count: number;
incomplete_results: boolean;
items: Array<{
number: number;
title: string;
pull_request: boolean;
}>;
}
export interface IGithubInterface {
getIssueOrPr(id: string): Promise<string | undefined>;
getDiscussion(id: string): Promise<string | undefined>;
getForkCount(): Promise<number>;
getStarCount(): Promise<number>;
search(options: SearchOptions): Promise<SearchResult>;
}
+39 -115
View File
@@ -1,121 +1,45 @@
import { dirname, importx } from '@discordx/importer';
import { CronJob } from 'cron';
import type { Interaction, Message, TextChannel } from 'discord.js';
import { IntentsBitField, Partials } from 'discord.js';
import { Client } from 'discordx';
import { Constants } from './constants.js';
import express from 'express';
import { githubWebhooks } from './controllers/webhooks/github.controller.js';
import { stripeWebhooks } from './controllers/webhooks/stripe.controller.js';
import { FileMigrationProvider, Migrator } from 'kysely';
import { db } from './db.js';
import path from 'node:path';
import { promises as fs } from 'node:fs';
import { logError } from './util.js';
import { INestApplication, Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DIService, IDependencyRegistryEngine, InstanceOf } from 'discordx';
import { AppModule } from 'src/app.module';
import { DiscordService } from 'src/services/discord.service';
export const bot = new Client({
// Discord intents
intents: [
IntentsBitField.Flags.Guilds,
IntentsBitField.Flags.GuildMessages,
IntentsBitField.Flags.GuildMembers,
IntentsBitField.Flags.MessageContent,
IntentsBitField.Flags.GuildMessageReactions,
],
// Debug logs are disabled in silent mode
silent: false,
// Configuration for @SimpleCommand
simpleCommand: {
prefix: '/',
},
partials: [Partials.Message, Partials.Reaction],
});
const birthdayJob = new CronJob(Constants.Misc.ImmichBirthdayCron, async () => {
const channel = (await bot.channels.fetch(Constants.Channels.General)) as TextChannel;
if (channel) {
await channel.send(`"Happy birthday my other child" - Alex`);
}
});
const app = express();
bot.once('ready', async () => {
const sha = process.env.COMMIT_SHA;
const commit = sha && `[${sha.substring(0, 8)}](https://github.com/immich-app/discord-bot/commit/${sha})`;
const fullVersion = commit && `${process.env.npm_package_version}@${commit}`;
// Synchronize applications commands with Discord
await bot.initApplicationCommands();
console.log(`Bot ${fullVersion} started`);
const channel = (await bot.channels.fetch(Constants.Channels.BotSpam)) as TextChannel;
if (channel && fullVersion) {
await channel.send(`I'm alive, running ${fullVersion}!`);
export class NoopRegistryEngine implements IDependencyRegistryEngine {
addService(): void {}
clearAllServices(): void {}
getAllServices(): Set<unknown> {
return new Set();
}
birthdayJob.start();
});
bot.on('interactionCreate', async (interaction: Interaction) => {
await bot.executeInteraction(interaction);
});
bot.on('messageCreate', async (message: Message) => {
await bot.executeCommand(message);
});
bot.on('error', (error) => logError('Error handling bot interaction', error, bot));
async function run() {
// The following syntax should be used in the commonjs environment
//
// await importx(__dirname + "/{events,commands}/**/*.{ts,js}");
// The following syntax should be used in the ECMAScript environment
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(import.meta.dirname, 'migrations'),
}),
});
const { error, results } = await migrator.migrateToLatest();
results?.forEach((it) => {
if (it.status === 'Success') {
console.log(`migration "${it.migrationName}" was executed successfully`);
} else if (it.status === 'Error') {
console.error(`failed to execute migration "${it.migrationName}"`);
}
});
if (error) {
console.error('failed to run db migrations');
console.error(error);
process.exit(1);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getService<T>(_classType: T): InstanceOf<T> | null {
console.log('NoopRegistryEngine.getService');
return null;
}
await importx(`${dirname(import.meta.url)}/{events,commands}/**/*.{ts,js}`);
// Let's start the bot
if (!process.env.BOT_TOKEN) {
throw Error('Could not find BOT_TOKEN in your environment');
}
// Log in with your bot token
app.use(express.json());
app.use('/webhooks', [githubWebhooks, stripeWebhooks]);
app.listen(8080, () => {
console.log('Bot listening on port 8080');
});
await bot.login(process.env.BOT_TOKEN);
}
await run();
export class NestjsRegistryEngine extends NoopRegistryEngine {
constructor(private app: INestApplication) {
super();
}
getService<T>(classType: T): InstanceOf<T> | null {
return this.app.get(classType as any);
}
}
async function bootstrap() {
const logger = new Logger('Main');
const port = Number(process.env.IMMICH_PORT) || 8080;
DIService.engine = new NoopRegistryEngine();
const app = await NestFactory.create<NestExpressApplication>(AppModule, { cors: true });
DIService.engine = new NestjsRegistryEngine(app);
await app.get(DiscordService).init();
await app.listen(port);
logger.log(`Immich Api is running on: ${await app.getUrl()}`);
}
void bootstrap();
@@ -0,0 +1,17 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable('sponsor')
.addColumn('username', 'varchar', (col) => col.primaryKey())
.addColumn('email', 'varchar', (col) => col.notNull())
.addColumn('total', 'integer', (col) => col.notNull())
.addColumn('claimed', 'boolean', (col) => col.notNull().defaultTo(false))
.addColumn('license_type', 'varchar', (col) => col.notNull())
.addColumn('licenses', 'jsonb', (col) => col.notNull())
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('sponsor').execute();
}
+87
View File
@@ -0,0 +1,87 @@
import { Logger } from '@nestjs/common';
import { FileMigrationProvider, Kysely, Migrator, PostgresDialect } from 'kysely';
import fs from 'node:fs/promises';
import path from 'node:path';
import pg from 'pg';
import { getConfig } from 'src/config';
import { Database, IDatabaseRepository, LicenseType, NewPayment } from 'src/interfaces/database.interface';
export class DatabaseRepository implements IDatabaseRepository {
private logger = new Logger(DatabaseRepository.name);
private db: Kysely<Database>;
constructor() {
const { database } = getConfig();
this.db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new pg.Pool({
connectionString: database.uri,
}),
}),
});
}
async runMigrations() {
const migrator = new Migrator({
db: this.db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(__dirname, '..', 'migrations'),
}),
});
const { error, results } = await migrator.migrateToLatest();
results?.forEach((it) => {
if (it.status === 'Success') {
this.logger.log(`migration "${it.migrationName}" was executed successfully`);
} else if (it.status === 'Error') {
this.logger.error(`failed to execute migration "${it.migrationName}"`);
}
});
if (error) {
this.logger.error(error, (error as Error)?.stack);
throw new Error('Failed to run database migrations');
}
}
async createPayment(entity: NewPayment) {
await this.db.insertInto('payment').values(entity).execute();
}
async getTotalLicenseCount() {
const result = await this.db
.selectFrom('payment')
.select([(b) => b.fn.count<number>('description').as('product_count'), 'description'])
.where('livemode', '=', true)
.where('status', '=', 'succeeded')
.groupBy('description')
.execute();
return {
server: result.find((r) => r.description === 'immich-server')?.product_count || 0,
client: result.find((r) => r.description === 'immich-client')?.product_count || 0,
};
}
async getSponsorLicenses(githubUsername: string) {
const sponsor = await this.db
.selectFrom('sponsor')
.selectAll()
.where('username', '=', githubUsername)
.executeTakeFirst();
if (!sponsor) {
return [];
}
await this.db.updateTable('sponsor').set('claimed', true).where('username', '=', githubUsername).execute();
return sponsor.licenses.map(({ activation, license }) => ({
type: sponsor.license_type === 'client' ? LicenseType.Client : LicenseType.Server,
licenseKey: license,
activationKey: activation,
}));
}
}
+52
View File
@@ -0,0 +1,52 @@
import { IntentsBitField, MessageCreateOptions, Partials } from 'discord.js';
import { Client } from 'discordx';
import EventEmitter from 'node:events';
import { DiscordChannel, IDiscordInterface } from 'src/interfaces/discord.interface';
const bot = new Client({
// Discord intents
intents: [
IntentsBitField.Flags.Guilds,
IntentsBitField.Flags.GuildMessages,
IntentsBitField.Flags.GuildMembers,
IntentsBitField.Flags.MessageContent,
IntentsBitField.Flags.GuildMessageReactions,
],
// Debug logs are disabled in silent mode
silent: false,
// Configuration for @SimpleCommand
simpleCommand: {
prefix: '/',
},
partials: [Partials.Message, Partials.Reaction],
});
export class DiscordRepository extends EventEmitter implements IDiscordInterface {
constructor() {
super();
bot
.on('ready', () => void this.emit('ready'))
.on('error', (error) => void this.emit('error', error))
.on('interactionCreate', (interaction) => bot.executeInteraction(interaction) as Promise<void>)
.on('messageCreate', (message) => bot.executeCommand(message) as Promise<void>);
}
async login(token: string) {
await bot.login(token);
}
initApplicationCommands(): Promise<void> {
return bot.initApplicationCommands();
}
async sendMessage(channel: DiscordChannel, message: MessageCreateOptions): Promise<void> {
const textChannel = await bot.channels.fetch(channel);
if (textChannel && textChannel.isTextBased()) {
await textChannel.send(message);
}
}
}
+6 -4
View File
@@ -1,9 +1,11 @@
import { Octokit } from '@octokit/rest';
import { IMMICH_REPOSITORY_BASE_OPTIONS, Constants } from '../constants.js';
import { RequestError } from '@octokit/request-error';
import { Octokit } from '@octokit/rest';
import { Constants, IMMICH_REPOSITORY_BASE_OPTIONS } from 'src/constants';
import { IGithubInterface } from 'src/interfaces/github.interface';
const octokit = new Octokit();
export class GithubRepository {
export class GithubRepository implements IGithubInterface {
async getIssueOrPr(id: string) {
try {
const response = await octokit.rest.issues.get({
@@ -55,6 +57,6 @@ export class GithubRepository {
}) {
return octokit.rest.search
.issuesAndPullRequests({ q: query, per_page, page, sort, order })
.then((response) => response.data);
.then((response) => response.data) as any;
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Provider } from '@nestjs/common';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IDiscordInterface } from 'src/interfaces/discord.interface';
import { IGithubInterface } from 'src/interfaces/github.interface';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { DiscordRepository } from 'src/repositories/discord.repository';
import { GithubRepository } from 'src/repositories/github.repository';
export const providers: Provider[] = [
//
{ provide: IDatabaseRepository, useClass: DatabaseRepository },
{ provide: IDiscordInterface, useClass: DiscordRepository },
{ provide: IGithubInterface, useClass: GithubRepository },
];
-86
View File
@@ -1,86 +0,0 @@
import { GithubRepository } from './repositories/github.repository.js';
export async function handleSearchAutocompletion(repository: GithubRepository, value: string) {
if (!value) {
return [];
}
try {
const result = await repository.search({
query: `repo:immich-app/immich in:title ${value}`,
per_page: 5,
page: 1,
sort: 'updated',
order: 'desc',
});
return result.items.map((item) => {
const name = `${item.pull_request ? '[PR]' : '[Issue]'} (${item.number}) ${item.title}`;
return {
name: name.length > 100 ? name.substring(0, 97) + '...' : name,
value: String(item.number),
};
});
} catch (error) {
console.log('Could not fetch search results from GitHub');
return [];
}
}
const _star_history: Record<string, number | undefined> = {};
export async function getStarsMessage(repository: GithubRepository, channelId: string) {
const lastStarsCount = _star_history[channelId];
try {
const starsCount = await repository.getStarCount();
const delta = lastStarsCount && starsCount - lastStarsCount;
const formattedDelta = delta && Intl.NumberFormat(undefined, { signDisplay: 'always' }).format(delta);
_star_history[channelId] = starsCount;
return `Stars ⭐: ${starsCount}${
formattedDelta ? ` (${formattedDelta} stars since the last call in this channel)` : ''
}`;
} catch (error) {
return 'Could not fetch stars count from the GitHub API';
}
}
const _fork_history: Record<string, number | undefined> = {};
export async function getForksMessage(repository: GithubRepository, channelId: string) {
const lastForksCount = _fork_history[channelId];
try {
const forksCount = await repository.getForkCount();
const delta = lastForksCount && forksCount - lastForksCount;
const formattedDelta = delta && Intl.NumberFormat(undefined, { signDisplay: 'always' }).format(delta);
_fork_history[channelId] = forksCount;
return `Forks: ${forksCount}${formattedDelta ? ` (${formattedDelta} forks since the last call in this channel)` : ''}`;
} catch (error) {
return 'Could not fetch forks count from the GitHub API';
}
}
export async function handleGithubReferences(repository: GithubRepository, content: string) {
content = content.replaceAll(/```.*```/gs, '');
const matches = content.matchAll(/(^|\W)#(?<id>[0-9]+)/g);
const ids = new Set<string>();
for (const match of matches) {
const id = match?.groups?.id;
if (!id) {
continue;
}
ids.add(id);
}
const filteredIds = ids.size > 1 ? [...ids].filter((id) => Number(id) > 500 && Number(id) < 15000) : [...ids];
const links = await Promise.all(
filteredIds.map(async (id) => (await repository.getIssueOrPr(id)) || (await repository.getDiscussion(id))),
);
return links.filter((link): link is string => link !== undefined);
}
+11
View File
@@ -0,0 +1,11 @@
import { Inject, Injectable } from '@nestjs/common';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
@Injectable()
export class DatabaseService {
constructor(@Inject(IDatabaseRepository) private repository: IDatabaseRepository) {}
async runMigrations() {
await this.repository.runMigrations();
}
}
@@ -1,8 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { IDiscordInterface } from 'src/interfaces/discord.interface';
import { IGithubInterface } from 'src/interfaces/github.interface';
import { DiscordService } from 'src/services/discord.service';
import { Mocked, describe, expect, it, vitest } from 'vitest';
import { GithubRepository } from './repositories/github.repository.js';
import { getForksMessage, getStarsMessage, handleGithubReferences, handleSearchAutocompletion } from './service.js';
const newRepository: () => Mocked<GithubRepository> = () => ({
const newGithubMockRepository = (): Mocked<IGithubInterface> => ({
search: vitest.fn(),
getDiscussion: vitest.fn(),
getForkCount: vitest.fn(),
@@ -10,30 +12,54 @@ const newRepository: () => Mocked<GithubRepository> = () => ({
getStarCount: vitest.fn(),
});
const newDiscordMockRepository = (): Mocked<IDiscordInterface> => ({
once: vitest.fn() as any,
on: vitest.fn() as any,
login: vitest.fn(),
initApplicationCommands: vitest.fn(),
sendMessage: vitest.fn(),
});
describe('Bot test', () => {
let sut: DiscordService;
let discordMock: Mocked<IDiscordInterface>;
let githubMock: Mocked<IGithubInterface>;
beforeEach(() => {
discordMock = newDiscordMockRepository();
discordMock.on.mockReturnValue(discordMock);
discordMock.once.mockReturnValue(discordMock);
githubMock = newGithubMockRepository();
sut = new DiscordService(discordMock, githubMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('handleSearchAutocompletion', () => {
it('should return nothing if search fails', async () => {
const repository = newRepository();
repository.search.mockRejectedValue('some error');
const result = await handleSearchAutocompletion(repository, 'test');
githubMock.search.mockRejectedValue('some error');
const result = await sut.handleSearchAutocompletion('test');
expect(result).toEqual([]);
expect(repository.search).toHaveBeenCalledWith(
expect(githubMock.search).toHaveBeenCalledWith(
expect.objectContaining({ query: `repo:immich-app/immich in:title test` }),
);
});
it('should return nothing if search string is empty', async () => {
const repository = newRepository();
const result = await handleSearchAutocompletion(repository, '');
const result = await sut.handleSearchAutocompletion('');
expect(result).toEqual([]);
expect(repository.search).not.toHaveBeenCalled();
expect(githubMock.search).not.toHaveBeenCalled();
});
it('should correctly map responses', async () => {
const repository = newRepository();
repository.search.mockResolvedValue({
githubMock.search.mockResolvedValue({
items: [
{
pull_request: { url: 'something', diff_url: null, html_url: null, patch_url: null },
@@ -47,12 +73,12 @@ describe('Bot test', () => {
],
} as never);
const result = await handleSearchAutocompletion(repository, 'first');
const result = await sut.handleSearchAutocompletion('first');
expect(result).toEqual([
{ name: '[PR] (123) my-first-pr', value: '123' },
{ name: '[Issue] (321) my-first-issue', value: '321' },
]);
expect(repository.search).toHaveBeenCalledWith(
expect(githubMock.search).toHaveBeenCalledWith(
expect.objectContaining({ query: `repo:immich-app/immich in:title first` }),
);
});
@@ -60,109 +86,101 @@ describe('Bot test', () => {
describe('getStarsMessage', () => {
it('should return an error message if the api call was unsuccessful', async () => {
const repository = newRepository();
repository.getStarCount.mockRejectedValue('error');
githubMock.getStarCount.mockRejectedValue('error');
const result = await getStarsMessage(repository, '123');
const result = await sut.getStarsMessage('123');
expect(result).toEqual('Could not fetch stars count from the GitHub API');
expect(repository.getStarCount).toHaveBeenCalled();
expect(githubMock.getStarCount).toHaveBeenCalled();
});
it('should return current star count', async () => {
const repository = newRepository();
repository.getStarCount.mockResolvedValue(42);
githubMock.getStarCount.mockResolvedValue(42);
const result = await getStarsMessage(repository, '1');
const result = await sut.getStarsMessage('1');
expect(result).toEqual('Stars ⭐: 42');
expect(repository.getStarCount).toHaveBeenCalled();
expect(githubMock.getStarCount).toHaveBeenCalled();
});
it('should include delta for subsequent calls', async () => {
const repository = newRepository();
repository.getStarCount.mockResolvedValueOnce(42);
repository.getStarCount.mockResolvedValueOnce(420);
githubMock.getStarCount.mockResolvedValueOnce(42);
githubMock.getStarCount.mockResolvedValueOnce(420);
const result = await getStarsMessage(repository, '2');
const result = await sut.getStarsMessage('2');
expect(result).toEqual('Stars ⭐: 42');
expect(repository.getStarCount).toHaveBeenCalledOnce();
expect(githubMock.getStarCount).toHaveBeenCalledOnce();
const secondResult = await getStarsMessage(repository, '2');
const secondResult = await sut.getStarsMessage('2');
expect(secondResult).toEqual('Stars ⭐: 420 (+378 stars since the last call in this channel)');
expect(repository.getStarCount).toHaveBeenCalledTimes(2);
expect(githubMock.getStarCount).toHaveBeenCalledTimes(2);
});
it('should not include delta if in different channels', async () => {
const repository = newRepository();
repository.getStarCount.mockResolvedValueOnce(42);
repository.getStarCount.mockResolvedValueOnce(420);
githubMock.getStarCount.mockResolvedValueOnce(42);
githubMock.getStarCount.mockResolvedValueOnce(420);
const result = await getStarsMessage(repository, '3');
const result = await sut.getStarsMessage('3');
expect(result).toEqual('Stars ⭐: 42');
expect(repository.getStarCount).toHaveBeenCalledOnce();
expect(githubMock.getStarCount).toHaveBeenCalledOnce();
const secondResult = await getStarsMessage(repository, '4');
const secondResult = await sut.getStarsMessage('4');
expect(secondResult).toEqual('Stars ⭐: 420');
expect(repository.getStarCount).toHaveBeenCalledTimes(2);
expect(githubMock.getStarCount).toHaveBeenCalledTimes(2);
});
});
describe('getForksMessage', () => {
it('should return an error message if the api call was unsuccessful', async () => {
const repository = newRepository();
repository.getForkCount.mockRejectedValue('error');
githubMock.getForkCount.mockRejectedValue('error');
const result = await getForksMessage(repository, '1');
const result = await sut.getForksMessage('1');
expect(result).toEqual('Could not fetch forks count from the GitHub API');
expect(repository.getForkCount).toHaveBeenCalled();
expect(githubMock.getForkCount).toHaveBeenCalled();
});
it('should return current star count', async () => {
const repository = newRepository();
repository.getForkCount.mockResolvedValue(42);
githubMock.getForkCount.mockResolvedValue(42);
const result = await getForksMessage(repository, '1');
const result = await sut.getForksMessage('1');
expect(result).toEqual('Forks: 42');
expect(repository.getForkCount).toHaveBeenCalled();
expect(githubMock.getForkCount).toHaveBeenCalled();
});
it('should include delta for subsequent calls', async () => {
const repository = newRepository();
repository.getForkCount.mockResolvedValueOnce(42);
repository.getForkCount.mockResolvedValueOnce(420);
githubMock.getForkCount.mockResolvedValueOnce(42);
githubMock.getForkCount.mockResolvedValueOnce(420);
const result = await getForksMessage(repository, '2');
const result = await sut.getForksMessage('2');
expect(result).toEqual('Forks: 42');
expect(repository.getForkCount).toHaveBeenCalledOnce();
expect(githubMock.getForkCount).toHaveBeenCalledOnce();
const secondResult = await getForksMessage(repository, '2');
const secondResult = await sut.getForksMessage('2');
expect(secondResult).toEqual('Forks: 420 (+378 forks since the last call in this channel)');
expect(repository.getForkCount).toHaveBeenCalledTimes(2);
expect(githubMock.getForkCount).toHaveBeenCalledTimes(2);
});
it('should not include delta if in different channels', async () => {
const repository = newRepository();
repository.getForkCount.mockResolvedValueOnce(42);
repository.getForkCount.mockResolvedValueOnce(420);
githubMock.getForkCount.mockResolvedValueOnce(42);
githubMock.getForkCount.mockResolvedValueOnce(420);
const result = await getForksMessage(repository, '3');
const result = await sut.getForksMessage('3');
expect(result).toEqual('Forks: 42');
expect(repository.getForkCount).toHaveBeenCalledOnce();
expect(githubMock.getForkCount).toHaveBeenCalledOnce();
const secondResult = await getForksMessage(repository, '4');
const secondResult = await sut.getForksMessage('4');
expect(secondResult).toEqual('Forks: 420');
expect(repository.getForkCount).toHaveBeenCalledTimes(2);
expect(githubMock.getForkCount).toHaveBeenCalledTimes(2);
});
});
@@ -178,10 +196,9 @@ describe('Bot test', () => {
{ name: 'find single unusual reference', content: '#123', referenceCount: 1 },
{ name: 'ignore references in code blocks', content: '```#4242 #123``` #6969', referenceCount: 1 },
])('should $name for PRs', async ({ content, referenceCount }) => {
const repository = newRepository();
repository.getIssueOrPr.mockResolvedValue('https://some-github-link/<id>');
githubMock.getIssueOrPr.mockResolvedValue('https://some-github-link/<id>');
await expect(handleGithubReferences(repository, content)).resolves.toEqual(
await expect(sut.handleGithubReferences(content)).resolves.toEqual(
Array(referenceCount).fill('https://some-github-link/<id>'),
);
});
@@ -197,10 +214,9 @@ describe('Bot test', () => {
{ name: 'find single unusual reference', content: '#123', referenceCount: 1 },
{ name: 'ignore references in code blocks', content: '```#4242 #123``` #6969', referenceCount: 1 },
])('should $name for discussions', async ({ content, referenceCount }) => {
const repository = newRepository();
repository.getDiscussion.mockResolvedValue('https://some-github-link/<id>');
githubMock.getDiscussion.mockResolvedValue('https://some-github-link/<id>');
await expect(handleGithubReferences(repository, content)).resolves.toEqual(
await expect(sut.handleGithubReferences(content)).resolves.toEqual(
Array(referenceCount).fill('https://some-github-link/<id>'),
);
});
+170
View File
@@ -0,0 +1,170 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { DateTime } from 'luxon';
import { getConfig } from 'src/config';
import { Constants, HELP_TEXTS, linkCommands } from 'src/constants';
import { DiscordChannel, IDiscordInterface } from 'src/interfaces/discord.interface';
import { IGithubInterface } from 'src/interfaces/github.interface';
import { logError } from 'src/util';
const _star_history: Record<string, number | undefined> = {};
const _fork_history: Record<string, number | undefined> = {};
@Injectable()
export class DiscordService {
private logger = new Logger(DiscordService.name);
constructor(
@Inject(IDiscordInterface) private discord: IDiscordInterface,
@Inject(IGithubInterface) private github: IGithubInterface,
) {
this.discord
//
.once('ready', () => this.onReady())
.on('error', (error) => this.onError(error));
}
async init() {
const { bot } = getConfig();
if (bot.token !== 'dev') {
await this.discord.login(bot.token);
}
}
@Cron(Constants.Cron.ImmichBirthday)
async onBirthday() {
await this.discord.sendMessage(DiscordChannel.General, `"Happy birthday my other child" - Alex`);
}
getHelpMessage(name: keyof typeof HELP_TEXTS) {
return HELP_TEXTS[name];
}
getLink(name: string, message: string | null) {
return message ? `${message}: ${linkCommands[name]}` : linkCommands[name];
}
getAge() {
const age = DateTime.now()
.diff(DateTime.fromObject({ year: 2022, month: 2, day: 3, hour: 15, minute: 56 }, { zone: 'UTC' }), [
'years',
'months',
'days',
'hours',
'minutes',
'seconds',
])
.toHuman({ listStyle: 'long', maximumFractionDigits: 0 });
return `Immich is ${age} old. ${Constants.Icons.Immich}`;
}
getReleaseNotes() {
return `Please make sure you have read and followed the release notes: ${Constants.Urls.Release}`;
}
async getStarsMessage(channelId: string) {
const lastStarsCount = _star_history[channelId];
try {
const starsCount = await this.github.getStarCount();
const delta = lastStarsCount && starsCount - lastStarsCount;
const formattedDelta = delta && Intl.NumberFormat(undefined, { signDisplay: 'always' }).format(delta);
_star_history[channelId] = starsCount;
return `Stars ⭐: ${starsCount}${
formattedDelta ? ` (${formattedDelta} stars since the last call in this channel)` : ''
}`;
} catch (error) {
return 'Could not fetch stars count from the GitHub API';
}
}
async getForksMessage(channelId: string) {
const lastForksCount = _fork_history[channelId];
try {
const forksCount = await this.github.getForkCount();
const delta = lastForksCount && forksCount - lastForksCount;
const formattedDelta = delta && Intl.NumberFormat(undefined, { signDisplay: 'always' }).format(delta);
_fork_history[channelId] = forksCount;
return `Forks: ${forksCount}${formattedDelta ? ` (${formattedDelta} forks since the last call in this channel)` : ''}`;
} catch (error) {
return 'Could not fetch forks count from the GitHub API';
}
}
async handleSearchAutocompletion(value: string) {
if (!value) {
return [];
}
try {
const result = await this.github.search({
query: `repo:immich-app/immich in:title ${value}`,
per_page: 5,
page: 1,
sort: 'updated',
order: 'desc',
});
return result.items.map((item) => {
const name = `${item.pull_request ? '[PR]' : '[Issue]'} (${item.number}) ${item.title}`;
return {
name: name.length > 100 ? name.substring(0, 97) + '...' : name,
value: String(item.number),
};
});
} catch (error) {
console.log('Could not fetch search results from GitHub');
return [];
}
}
async handleGithubReferences(content: string) {
content = content.replaceAll(/```.*```/gs, '');
const matches = content.matchAll(/(^|\W)#(?<id>[0-9]+)/g);
const ids = new Set<string>();
for (const match of matches) {
const id = match?.groups?.id;
if (!id) {
continue;
}
ids.add(id);
}
const filteredIds = ids.size > 1 ? [...ids].filter((id) => Number(id) > 500 && Number(id) < 15000) : [...ids];
const links = await Promise.all(
filteredIds.map(async (id) => (await this.github.getIssueOrPr(id)) || (await this.github.getDiscussion(id))),
);
return links.filter((link): link is string => link !== undefined);
}
getPrOrIssue(id: string) {
return this.github.getIssueOrPr(id);
}
private async onReady() {
this.logger.log('Bot.onReady');
const { commitSha: sha } = getConfig();
const commit = sha && `[${sha.substring(0, 8)}](https://github.com/immich-app/discord-bot/commit/${sha})`;
const fullVersion = commit && `${process.env.npm_package_version}@${commit}`;
// Synchronize applications commands with Discord
await this.discord.initApplicationCommands();
this.logger.log(`Bot ${fullVersion} started`);
if (fullVersion) {
await this.discord.sendMessage(DiscordChannel.BotSpam, `I'm alive, running ${fullVersion}!`);
}
}
private async onError(error: Error) {
await logError('Discord bot error', error, { discord: this.discord, logger: this.logger });
}
}
+6
View File
@@ -0,0 +1,6 @@
import { DatabaseService } from 'src/services/database.service';
import { DiscordService } from './discord.service';
import { OAuthService } from './oauth.service';
import { WebhookService } from './webhook.service';
export const services = [DatabaseService, DiscordService, OAuthService, WebhookService];
+82
View File
@@ -0,0 +1,82 @@
import { BadRequestException, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { Client, Issuer, generators } from 'openid-client';
import { getConfig } from 'src/config';
import { OAuthAuthorizeDto, OAuthCallbackDto } from 'src/dtos/oauth.dto';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
type GithubProfile = {
login: string;
id: string;
avatar_url: string;
url: string;
type: 'User';
name: string;
created_at: string;
updated_at: string;
};
type StateItem = { value: string; expiresAt: number };
const stateMap = new Map<string, StateItem>();
@Injectable()
export class OAuthService {
private logger: Logger = new Logger(OAuthService.name);
private client: Client;
constructor(@Inject(IDatabaseRepository) private database: IDatabaseRepository) {
const issuer = new Issuer({
issuer: 'https://github.com',
authorization_endpoint: 'https://github.com/login/oauth/authorize',
token_endpoint: 'https://github.com/login/oauth/access_token',
userinfo_endpoint: 'https://api.github.com/user',
});
const { github } = getConfig();
this.client = new issuer.Client({
client_id: github.clientId,
client_secret: github.clientSecret,
});
}
authorize(dto: OAuthAuthorizeDto) {
const state = generators.state();
stateMap.set(state, { value: state, expiresAt: Date.now() + 5 * 60 * 1000 });
return {
url: this.client.authorizationUrl({
state,
scope: 'openid profile email',
redirect_uri: dto.redirectUri,
}),
};
}
async callback({ url }: OAuthCallbackDto) {
try {
const redirectUri = new URL(url).origin + '/claim/callback';
const params = this.client.callbackParams(url);
if (!params.state || !stateMap.has(params.state)) {
throw new BadRequestException('Invalid state parameter');
}
const stateItem = stateMap.get(params.state);
if (!stateItem || stateItem.expiresAt < Date.now()) {
throw new BadRequestException('Invalid state parameter');
}
const tokens = await this.client.oauthCallback(redirectUri, params, { state: stateItem.value });
const profile = await this.client.userinfo<GithubProfile>(tokens);
const licenses = await this.database.getSponsorLicenses(profile.login);
return {
username: profile.login,
imageUrl: profile.avatar_url,
licenses,
};
} catch (error: Error | AggregateError | unknown) {
this.logger.error(error, (error as Error)?.stack, (error as AggregateError)?.errors);
throw new InternalServerErrorException('An error occurred while processing the request');
}
}
}
+132
View File
@@ -0,0 +1,132 @@
import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { Colors, EmbedBuilder } from 'discord.js';
import { getConfig } from 'src/config';
import { GithubStatusComponent, GithubStatusIncident, PaymentIntent, StripeBase } from 'src/dtos/webhook.dto';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { DiscordChannel, IDiscordInterface } from 'src/interfaces/discord.interface';
import { withErrorLogging } from 'src/util';
const isIncidentUpdate = (dto: GithubStatusComponent | GithubStatusIncident): dto is GithubStatusIncident => {
return !!(dto as GithubStatusIncident).incident;
};
const isPaymentEvent = (payload: StripeBase): payload is StripeBase<PaymentIntent> =>
payload.data.object.object === 'payment_intent';
const isImmichProduct = (payload: StripeBase<PaymentIntent>) =>
['immich-server', 'immich-client'].includes(payload.data.object.description);
@Injectable()
export class WebhookService {
private logger = new Logger(WebhookService.name);
constructor(
@Inject(IDatabaseRepository) private database: IDatabaseRepository,
@Inject(IDiscordInterface) private discord: IDiscordInterface,
) {}
async onGithubStatus(dto: GithubStatusIncident | GithubStatusComponent, slug: string) {
const { slugs } = getConfig();
if (!slugs.githubWebhook || slug !== slugs.githubWebhook) {
throw new UnauthorizedException();
}
this.logger.debug(dto);
if (isIncidentUpdate(dto)) {
const embed = new EmbedBuilder({
title: dto.page.status_description,
author: { name: 'GitHub Status', url: 'https://githubstatus.com' },
url: dto.incident.shortlink,
fields: [{ name: dto.incident.name, value: dto.incident.incident_updates[0].body.replaceAll('<br />', '\n') }],
});
if (dto.incident.status === 'resolved') {
embed.setColor('Green');
} else {
switch (dto.incident.impact) {
case 'minor':
embed.setColor('Orange');
break;
case 'major':
embed.setColor('Red');
break;
default:
embed.setColor('Grey');
}
}
await this.discord.sendMessage(DiscordChannel.GithubStatus, { embeds: [embed] });
}
}
onStripePayment(dto: StripeBase, slug: string) {
const { slugs } = getConfig();
if (!slugs.stripeWebhook || slug !== slugs.stripeWebhook) {
throw new UnauthorizedException();
}
if (isPaymentEvent(dto) && isImmichProduct(dto)) {
void this.handleStripePayment(dto);
}
}
private async handleStripePayment(event: StripeBase<PaymentIntent>) {
const { id, description, amount, created, currency, status, livemode } = event.data.object;
await withErrorLogging({
method: () =>
this.database.createPayment({
event_id: event.id,
id,
amount,
currency,
status,
description,
created,
livemode,
data: JSON.stringify(event),
}),
message: 'Failed to insert payment into database',
fallbackValue: undefined,
discord: this.discord,
logger: this.logger,
});
if (status !== 'succeeded') {
return;
}
const { server, client } = await withErrorLogging({
method: () => this.database.getTotalLicenseCount(),
message: 'Failed to insert payment into database',
fallbackValue: { server: 0, client: 0 },
discord: this.discord,
logger: this.logger,
});
const licenseType = description.split('-')[1];
await this.discord.sendMessage(DiscordChannel.Stripe, {
embeds: [
new EmbedBuilder()
.setTitle(`${livemode ? '' : 'TEST PAYMENT - '}Immich ${licenseType} license purchased`)
.setURL(`https://dashboard.stripe.com/${livemode ? '' : 'test/'}payments/${id}`)
.setAuthor({ name: 'Stripe Payments', url: 'https://stripe.com' })
.setDescription(`Price: ${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`)
.setColor(livemode ? Colors.Green : Colors.Yellow)
.setFields([
{
name: 'Server licenses',
value: `$${(server * 99.99).toFixed(2)} - ${server} licenses`,
inline: true,
},
{
name: 'Client licenses',
value: `$${(client * 24.99).toFixed(2)} - ${client} licenses`,
inline: true,
},
]),
],
});
}
}
+23 -7
View File
@@ -1,13 +1,29 @@
import { Constants } from './constants.js';
import type { TextChannel } from 'discord.js';
import { Client } from 'discordx';
import { Logger } from '@nestjs/common';
import { DiscordChannel, IDiscordInterface } from 'src/interfaces/discord.interface';
export const logError = async (message: string, error: unknown, bot: Client) => {
console.error(message, error);
type Repos = { discord: IDiscordInterface; logger: Logger };
export const logError = async (message: string, error: unknown, { discord, logger }: Repos) => {
logger.error(message, error);
try {
const botSpamChannel = (await bot.channels.fetch(Constants.Channels.BotSpam)) as TextChannel;
await botSpamChannel.send(`${message}: ${error}`);
await discord.sendMessage(DiscordChannel.BotSpam, `${message}: ${error}`);
} catch (error) {
console.error('Failed to send error message to bot spam channel', error);
}
};
type WithErrorOptions<T> = Repos & {
message: string;
method: () => Promise<T>;
fallbackValue: T;
discord: IDiscordInterface;
logger: Logger;
};
export const withErrorLogging = async <T = unknown>(options: WithErrorOptions<T>) => {
const { message, method, fallbackValue, discord, logger } = options;
try {
return await method();
} catch (error) {
await logError(message, error, { discord, logger });
return fallbackValue;
}
};
+18 -14
View File
@@ -1,21 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"outDir": "build",
"rootDir": "src",
"module": "node16",
"strict": true,
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"moduleResolution": "node16",
"lib": ["dom", "es2023"],
"sourceMap": true,
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
"esModuleInterop": true,
"preserveWatchOutput": true,
"baseUrl": "./",
"jsx": "react",
"types": ["vitest/globals"],
},
"ts-node": {
"esm": true,
},
"exclude": ["build", "node_modules"],
"exclude": ["dist", "node_modules", "upload"],
}
+2
View File
@@ -1,3 +1,4 @@
import swc from 'unplugin-swc';
import { defineConfig } from 'vitest/config';
export default defineConfig({
@@ -10,4 +11,5 @@ export default defineConfig({
},
},
},
plugins: [swc.vite()],
});