diff --git a/app-nest/manifest.yaml b/app-nest/manifest.yaml new file mode 100644 index 0000000..21d29e7 --- /dev/null +++ b/app-nest/manifest.yaml @@ -0,0 +1,48 @@ +display_information: + name: Hybridilusmu + description: Hybridityöskentelyn tukisovellus +features: + app_home: + home_tab_enabled: true + messages_tab_enabled: true + messages_tab_read_only_enabled: false + bot_user: + display_name: hybridilusmu + always_online: true +oauth_config: + scopes: + bot: + - channels:history + - channels:join + - chat:write + - groups:history + - groups:read + - im:history + - im:read + - mpim:history + - channels:read + - reactions:read + - usergroups:read + - commands + - users:read +settings: + event_subscriptions: + bot_events: + - member_joined_channel + - member_left_channel + - message.channels + - message.groups + - message.im + - message.mpim + - reaction_added + - app_home_opened + - subteam_created + - subteam_members_changed + - subteam_updated + - channel_left + - user_profile_changed + interactivity: + is_enabled: true + org_deploy_enabled: false + socket_mode_enabled: true + token_rotation_enabled: false diff --git a/app-nest/src/app.module.ts b/app-nest/src/app.module.ts index d885ffd..e3e0f1f 100644 --- a/app-nest/src/app.module.ts +++ b/app-nest/src/app.module.ts @@ -3,8 +3,10 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { BoltModule } from "./bolt/bolt.module"; import configuration from "./config/configuration"; import { inDevelopmentEnvironment } from "./config/utils"; +import { DevToolsModule } from "./dev-tools/dev-tools.module"; import { EntitiesModule } from "./entities/entities.module"; import { GuiModule } from "./gui/gui.module"; +import { SyncModule } from "./sync/sync.module"; @Module({ imports: [ @@ -25,6 +27,8 @@ import { GuiModule } from "./gui/gui.module"; }), GuiModule, EntitiesModule, + SyncModule, + DevToolsModule, ], }) export class AppModule {} diff --git a/app-nest/src/bolt/bolt-register.service.ts b/app-nest/src/bolt/bolt-register.service.ts index 8974d61..69057f9 100644 --- a/app-nest/src/bolt/bolt-register.service.ts +++ b/app-nest/src/bolt/bolt-register.service.ts @@ -1,11 +1,17 @@ -import { DiscoveryService } from "@golevelup/nestjs-discovery"; +import { + DiscoveredMethod, + DiscoveryService, +} from "@golevelup/nestjs-discovery"; import { Injectable } from "@nestjs/common"; import { ModuleRef } from "@nestjs/core"; -import { App, Middleware, SlackEventMiddlewareArgs } from "@slack/bolt"; +import { App } from "@slack/bolt"; import { StringIndexed } from "@slack/bolt/dist/types/helpers"; import { BoltService } from "./bolt.service"; +import { BOLT_ACTION_KEY } from "./decorators/bolt-action.decorator"; import { BOLT_EVENT_KEY } from "./decorators/bolt-event.decorator"; +type EventType = typeof BOLT_ACTION_KEY | typeof BOLT_EVENT_KEY; + @Injectable() export class BoltRegisterService { private bolt: App; @@ -18,27 +24,57 @@ export class BoltRegisterService { this.bolt = boltService.getBolt(); } - async registerEvents() { - // TODO: This needs a type parameter. + /** + * Register all controller methods decorated with one of our Bolt event decorators. + */ + async registerAllHandlers() { + const eventTypes = [BOLT_ACTION_KEY, BOLT_EVENT_KEY] as const; + + await Promise.all( + eventTypes.map((eventType) => this.registerHandlers(eventType)), + ); + } + + /** + * Register all handler functions of certain `EventType`. + */ + private async registerHandlers(eventType: EventType) { const controllers = - await this.discoveryService.controllerMethodsWithMetaAtKey( - BOLT_EVENT_KEY, - ); + await this.discoveryService.controllerMethodsWithMetaAtKey(eventType); controllers.forEach((controller) => { - const { meta } = controller; - const cref = this.moduleRef.get( - controller.discoveredMethod.parentClass.injectType, - { strict: false }, - ); - this.registerEventHandler(meta.toString(), cref.getView()); + const { meta, discoveredMethod } = controller; + const eventName = meta.toString(); + const handler = this.getHandler(discoveredMethod); + + this.registerHandler(eventType, eventName, handler); + }); + } + + /** + * Get handler function via `ModuleRef` and bind it to correct context. + */ + private getHandler(discoveredMethod: DiscoveredMethod) { + const cref = this.moduleRef.get(discoveredMethod.parentClass.injectType, { + strict: false, }); + // N.B.: Take care where you call the discovered handler method. This is an easy + // place to bind it into a wrong context. Ask me how I know. + return cref[discoveredMethod.methodName](); } - private registerEventHandler( + /** + * Register handler function with Bolt API. + */ + private registerHandler( + eventType: EventType, eventName: string, - listener: Middleware, StringIndexed>, + handler: any, ) { - this.bolt.event(eventName, listener); + if (eventType === BOLT_ACTION_KEY) { + this.bolt.action(eventName, handler); + } else if (eventType === BOLT_EVENT_KEY) { + this.bolt.event(eventName, handler); + } } } diff --git a/app-nest/src/bolt/bolt-user.service.ts b/app-nest/src/bolt/bolt-user.service.ts new file mode 100644 index 0000000..d31fcf8 --- /dev/null +++ b/app-nest/src/bolt/bolt-user.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from "@nestjs/common"; +import { BoltService } from "./bolt.service"; + +@Injectable() +export class BoltUserService { + constructor(private boltService: BoltService) {} + + async getUsers() { + return this.boltService.getBolt().client.users.list(); + } +} diff --git a/app-nest/src/bolt/bolt.module.ts b/app-nest/src/bolt/bolt.module.ts index 3a7bced..354bcbd 100644 --- a/app-nest/src/bolt/bolt.module.ts +++ b/app-nest/src/bolt/bolt.module.ts @@ -1,18 +1,25 @@ import { DiscoveryModule } from "@golevelup/nestjs-discovery"; -import { Global, Module, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { + Global, + Module, + OnApplicationBootstrap, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; import { BoltRegisterService } from "./bolt-register.service"; +import { BoltUserService } from "./bolt-user.service"; import { ConfigurableBoltModule } from "./bolt.module-definition"; import { BoltService } from "./bolt.service"; @Global() @Module({ imports: [DiscoveryModule], - providers: [BoltService, BoltRegisterService], - exports: [BoltService], + providers: [BoltService, BoltRegisterService, BoltUserService], + exports: [BoltService, BoltUserService], }) export class BoltModule extends ConfigurableBoltModule - implements OnModuleInit, OnModuleDestroy + implements OnModuleInit, OnApplicationBootstrap, OnModuleDestroy { constructor( private boltService: BoltService, @@ -23,7 +30,10 @@ export class BoltModule async onModuleInit() { await this.boltService.connect(); - await this.boltRegisterService.registerEvents(); + } + + async onApplicationBootstrap() { + await this.boltRegisterService.registerAllHandlers(); } async onModuleDestroy() { diff --git a/app-nest/src/bolt/decorators/bolt-action.decorator.ts b/app-nest/src/bolt/decorators/bolt-action.decorator.ts new file mode 100644 index 0000000..0a0a650 --- /dev/null +++ b/app-nest/src/bolt/decorators/bolt-action.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from "@nestjs/common"; + +export const BOLT_ACTION_KEY = "BoltAction"; + +const BoltAction = (actionName: string) => + SetMetadata(BOLT_ACTION_KEY, actionName); + +export default BoltAction; diff --git a/app-nest/src/dev-tools/dev-tools.controller.ts b/app-nest/src/dev-tools/dev-tools.controller.ts new file mode 100644 index 0000000..4748c3a --- /dev/null +++ b/app-nest/src/dev-tools/dev-tools.controller.ts @@ -0,0 +1,16 @@ +import { Controller } from "@nestjs/common"; +import BoltAction from "../bolt/decorators/bolt-action.decorator"; +import { UserSyncService } from "../sync/user-sync.service"; + +@Controller() +export class DevToolsController { + constructor(private userSyncService: UserSyncService) {} + + @BoltAction("sync_users") + syncUsers() { + return async ({ ack }) => { + await ack(); + await this.userSyncService.syncUsers(); + }; + } +} diff --git a/app-nest/src/dev-tools/dev-tools.module.ts b/app-nest/src/dev-tools/dev-tools.module.ts new file mode 100644 index 0000000..5479897 --- /dev/null +++ b/app-nest/src/dev-tools/dev-tools.module.ts @@ -0,0 +1,6 @@ +import { Module } from "@nestjs/common"; +import { SyncModule } from "../sync/sync.module"; +import { DevToolsController } from "./dev-tools.controller"; + +@Module({ imports: [SyncModule], controllers: [DevToolsController] }) +export class DevToolsModule {} diff --git a/app-nest/src/entities/user/user.entity.ts b/app-nest/src/entities/user/user.entity.ts index d34e4fe..f417c5b 100644 --- a/app-nest/src/entities/user/user.entity.ts +++ b/app-nest/src/entities/user/user.entity.ts @@ -1,12 +1,15 @@ -import { Column, Entity, PrimaryGeneratedColumn, Repository } from "typeorm"; +import { Column, Entity, PrimaryColumn, Repository } from "typeorm"; @Entity() export class User { - @PrimaryGeneratedColumn() - id: number; + @PrimaryColumn() + slackId: string; @Column() - slackId: string; + displayName: string; + + @Column() + realName: string; } export type UserRepository = Repository; diff --git a/app-nest/src/entities/user/user.service.ts b/app-nest/src/entities/user/user.service.ts index 3e964c7..f69c452 100644 --- a/app-nest/src/entities/user/user.service.ts +++ b/app-nest/src/entities/user/user.service.ts @@ -1,12 +1,26 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; import { User, UserRepository } from "./user.entity"; @Injectable() export class UserService { - constructor(@InjectRepository(User) private userRepository: UserRepository) {} + constructor( + @InjectRepository(User) private userRepository: UserRepository, + private dataSource: DataSource, + ) {} async findAll() { return this.userRepository.find(); } + + async upsert(users: User[]) { + return this.dataSource + .createQueryBuilder() + .insert() + .into(User) + .values(users) + .orUpdate(["displayName", "realName"], ["slackId"]) + .execute(); + } } diff --git a/app-nest/src/gui/dev/dev-tools.ts b/app-nest/src/gui/dev/dev-tools.ts new file mode 100644 index 0000000..b0e8b9f --- /dev/null +++ b/app-nest/src/gui/dev/dev-tools.ts @@ -0,0 +1,36 @@ +const devTools = [ + { + type: "header", + text: { + type: "plain_text", + text: ":wrench: Developer Tools", + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: ":recycle: Sync Users", + }, + action_id: "sync_users", + }, + ], + }, + { + type: "context", + elements: [ + { + type: "plain_text", + text: "In development environment, users are not synchronized between local database and Slack on app start to avoid running into API rate limits due to hot-reloads. Sync users manually when necessary.", + }, + ], + }, + { + type: "divider", + }, +]; + +export default devTools; diff --git a/app-nest/src/gui/tabs/home-tab.controller.ts b/app-nest/src/gui/tabs/home-tab.controller.ts index 82168d6..58bb5cf 100644 --- a/app-nest/src/gui/tabs/home-tab.controller.ts +++ b/app-nest/src/gui/tabs/home-tab.controller.ts @@ -1,6 +1,7 @@ import { Controller } from "@nestjs/common"; import BoltEvent from "../../bolt/decorators/bolt-event.decorator"; import { UserService } from "../../entities/user/user.service"; +import devTools from "../dev/dev-tools"; @Controller() export class HomeTabController { @@ -11,12 +12,14 @@ export class HomeTabController { return async ({ event, client, logger }) => { const users = await this.userService.findAll(); const { slackId } = users[0]; + try { const result = await client.views.publish({ user_id: event.user, view: { type: "home", blocks: [ + ...devTools, { type: "section", text: { diff --git a/app-nest/src/sync/sync.controller.ts b/app-nest/src/sync/sync.controller.ts new file mode 100644 index 0000000..f8f11fc --- /dev/null +++ b/app-nest/src/sync/sync.controller.ts @@ -0,0 +1,13 @@ +import { Controller } from "@nestjs/common"; +import BoltEvent from "../bolt/decorators/bolt-event.decorator"; +import { UserSyncService } from "./user-sync.service"; + +@Controller() +export class SyncController { + constructor(private userSyncService: UserSyncService) {} + + @BoltEvent("user_profile_changed") + userProfileChanged() { + return ({ event }) => this.userSyncService.syncUsers(event.user); + } +} diff --git a/app-nest/src/sync/sync.module.ts b/app-nest/src/sync/sync.module.ts new file mode 100644 index 0000000..81d57e4 --- /dev/null +++ b/app-nest/src/sync/sync.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { UserModule } from "../entities/user/user.module"; +import { UserService } from "../entities/user/user.service"; +import { SyncController } from "./sync.controller"; +import { SyncService } from "./sync.service"; +import { UserSyncService } from "./user-sync.service"; + +@Module({ + imports: [UserModule], + providers: [SyncService, UserSyncService, UserService], + controllers: [SyncController], + exports: [SyncService, UserSyncService], +}) +export class SyncModule {} diff --git a/app-nest/src/sync/sync.service.ts b/app-nest/src/sync/sync.service.ts new file mode 100644 index 0000000..fc33258 --- /dev/null +++ b/app-nest/src/sync/sync.service.ts @@ -0,0 +1,14 @@ +import { Injectable, OnApplicationBootstrap } from "@nestjs/common"; +import { inDevelopmentEnvironment } from "../config/utils"; +import { UserSyncService } from "./user-sync.service"; + +@Injectable() +export class SyncService implements OnApplicationBootstrap { + constructor(private userSyncService: UserSyncService) {} + + async onApplicationBootstrap() { + if (!inDevelopmentEnvironment) { + await this.userSyncService.syncUsers(); + } + } +} diff --git a/app-nest/src/sync/user-sync.service.ts b/app-nest/src/sync/user-sync.service.ts new file mode 100644 index 0000000..c033182 --- /dev/null +++ b/app-nest/src/sync/user-sync.service.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { UsersListResponse } from "@slack/web-api"; +import { BoltUserService } from "../bolt/bolt-user.service"; +import { UserService } from "../entities/user/user.service"; + +type SlackMember = UsersListResponse["members"][0]; + +@Injectable() +export class UserSyncService { + private logger = new Logger(UserSyncService.name); + + constructor( + private boltUserService: BoltUserService, + private userService: UserService, + ) {} + + /** + * Synchronize users from Slack to local database. + * + * By default, all users are synchronized. Optionally, a single user may be + * updated by passing their data as `updateOverride` argument. + */ + async syncUsers(updateOverride?: SlackMember) { + if (updateOverride) { + this.logger.log( + `Starting user data synchronization for user ${updateOverride.id}.`, + ); + } else { + this.logger.log("Starting user data synchronization for all users."); + } + + const slackUsers = updateOverride + ? [updateOverride] + : (await this.boltUserService.getUsers()).members; + + const users = slackUsers.filter(this.appUserFilter).map((user) => ({ + slackId: user.id, + displayName: user.profile.display_name || "", + realName: user.profile.real_name || "", + })); + + await this.userService.upsert(users); + this.logger.log("User data synchronized."); + } + + /** + * Filter out bots, restricted and deleted users, leaving only real app users. + */ + private appUserFilter(user: SlackMember) { + return ( + user.id !== "USLACKBOT" && + !user.is_bot && + !user.is_restricted && + !user.is_ultra_restricted && + !user.deleted + ); + } +}