mirror of
https://github.com/immich-app/discord-bot.git
synced 2026-09-30 13:32:52 +08:00
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:
co-authored by
Zack Pollard
parent
0dc46e0c1c
commit
526af047a0
+2
-1
@@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
/build
|
||||
/build
|
||||
/dist
|
||||
|
||||
+1
-2
@@ -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
@@ -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
@@ -6,5 +6,6 @@
|
||||
|
||||
# folders
|
||||
build
|
||||
dist
|
||||
node_modules
|
||||
coverage
|
||||
coverage
|
||||
|
||||
+1
-2
@@ -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
@@ -4,5 +4,5 @@
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"organizeImportsSkipDestructiveCodeActions": true,
|
||||
"pluginSearchDirs": false
|
||||
"plugins": ["prettier-plugin-organize-imports"]
|
||||
}
|
||||
|
||||
+8
-22
@@ -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"]
|
||||
|
||||
Generated
+2917
-163
File diff suppressed because it is too large
Load Diff
+31
-9
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { OAuthController } from 'src/controllers/oauth.controller';
|
||||
import { WebhookController } from 'src/controllers/webhook.controller';
|
||||
|
||||
export const controllers = [OAuthController, WebhookController];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class OAuthAuthorizeDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
redirectUri!: string;
|
||||
}
|
||||
|
||||
export class OAuthCallbackDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
url!: string;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>'),
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user