diff --git a/.github/workflows/_reusable_check.yml b/.github/workflows/_reusable_check.yml index ed733fa..5f1dcf8 100644 --- a/.github/workflows/_reusable_check.yml +++ b/.github/workflows/_reusable_check.yml @@ -25,6 +25,9 @@ jobs: - name: Biome Check run: npm run check + - name: Test + run: npm test + - name: TypeCheck run: npm run typecheck diff --git a/.github/workflows/_reusable_lambda_rie_test.yml b/.github/workflows/_reusable_lambda_rie_test.yml index 940037c..782dad8 100644 --- a/.github/workflows/_reusable_lambda_rie_test.yml +++ b/.github/workflows/_reusable_lambda_rie_test.yml @@ -18,7 +18,7 @@ jobs: - run: npm ci - name: Bundle handler - run: npx esbuild index.ts --bundle --platform=node --target=node24 --external:@slack/bolt --outfile=dist/index.js + run: npx esbuild src/handler.ts --bundle --platform=node --target=node24 --external:@slack/bolt --outfile=dist/index.js - name: Prepare Lambda artifact run: cp -R node_modules dist/ diff --git a/index.ts b/index.ts deleted file mode 100644 index 949b735..0000000 --- a/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -// index.ts -import { App, AwsLambdaReceiver } from '@slack/bolt'; -import type { GenericMessageEvent } from '@slack/types'; - -const SLACK_SIGNING_SECRET = process.env['SLACK_SIGNING_SECRET']; -if (!SLACK_SIGNING_SECRET) throw new Error('signing secret is undefined'); - -// AWS Lambdaのレシーバーを作成 -const awsLambdaReceiver = new AwsLambdaReceiver({ - signingSecret: SLACK_SIGNING_SECRET, -}); - -// Slack Appの設定情報 -const app = new App({ - token: process.env['SLACK_BOT_TOKEN'], - receiver: awsLambdaReceiver, -}); - -const TIMES_ALL_CHANNEL_ID = process.env['TIMES_ALL_CHANNEL_ID']; // `times-all` チャンネルのIDを設定 -if (!TIMES_ALL_CHANNEL_ID) throw new Error('times-all channel is undefined'); -const WORKSPACE_URL = process.env['WORKSPACE_URL']; // SlackワークスペースのURLを設定 -if (!WORKSPACE_URL) throw new Error('workspace is undefined'); - -// 型ガード関数 -function isGenericMessageEvent(event: unknown): event is GenericMessageEvent { - return (event as GenericMessageEvent).type === 'message'; -} - -// 'times-'プレフィックスがついたチャンネルのメッセージを検出 -app.message(async ({ message, client }) => { - console.log('received message'); - if (!isGenericMessageEvent(message)) { - console.log('not a message event'); - return; - } - // メッセージがチャンネルからのものかどうか確認 - if ( - 'channel' in message && - message.channel_type === 'channel' && - message.channel !== TIMES_ALL_CHANNEL_ID - ) { - console.log('time-all以外のチャンネル'); - // チャンネルの情報を取得 - const channelInfo = await client.conversations.info({ - channel: message.channel, - }); - console.log('channelInfo:', channelInfo); - // チャンネル名が'times-'で始まる場合、メッセージを'times-all'に転載 - if (channelInfo.channel?.name?.startsWith('times-')) { - console.log('channel name starts with times-'); - if ('hidden' in message) { - return; - } //hidden subtypeがある場合は無視 - // https://api.slack.com/events/message#hidden_subtypes - if ('subtype' in message && message.subtype === 'channel_join') { - return; - } //チャンネル参加メッセージは無視 - const messageTimestamp = message.ts.replace('.', ''); - const messageLink = `${WORKSPACE_URL}/archives/${message.channel}/p${messageTimestamp}`; - console.log('messageLink:', messageLink); - await client.chat.postMessage({ - channel: TIMES_ALL_CHANNEL_ID, - text: `<${messageLink}|このメッセージ> が <#${message.channel}> で投稿されました。`, - }); - } else { - console.log(channelInfo.channel?.name); - } - } -}); - -app.event('channel_created', async ({ event, client, logger }) => { - if (event.channel.name?.startsWith('times-')) { - logger.debug(event.channel.name); - await client.conversations.join({ channel: event.channel.id }); - logger.info(`Joined channel: ${event.channel.name}`); - } -}); - -// Lambdaのイベント処理 -// Node.js 24ではcallbackベースのハンドラーが廃止されたため、 -// AwsLambdaReceiverのハンドラーを2引数のasync関数でラップする -// ref: https://github.com/slackapi/bolt-js/issues/2761 -export const handler = async ( - event: Record, - context: unknown, -) => { - const boltHandler = await awsLambdaReceiver.start(); - // boltHandlerは内部的にcallbackを使用しないが、型定義上3引数が必須 - // biome-ignore lint/suspicious/noExplicitAny: AwsEventの型がexportされていないため - return boltHandler(event as any, context, () => {}); -}; diff --git a/infra/lib/times-all-bot-stack.ts b/infra/lib/times-all-bot-stack.ts index 3ee27c4..47b96c1 100644 --- a/infra/lib/times-all-bot-stack.ts +++ b/infra/lib/times-all-bot-stack.ts @@ -6,33 +6,26 @@ import * as iam from 'aws-cdk-lib/aws-iam'; import * as runtime from 'aws-cdk-lib/aws-lambda'; import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; import type { Construct } from 'constructs'; +import { loadEnv } from '../../src/env.ts'; export class TimesAllBotStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); - const slackBotToken = process.env['SLACK_BOT_TOKEN']; - if (!slackBotToken) throw new Error('SLACK_BOT_TOKEN is required'); - const slackSigningSecret = process.env['SLACK_SIGNING_SECRET']; - if (!slackSigningSecret) - throw new Error('SLACK_SIGNING_SECRET is required'); - const timesAllChannelId = process.env['TIMES_ALL_CHANNEL_ID']; - if (!timesAllChannelId) throw new Error('TIMES_ALL_CHANNEL_ID is required'); - const workspaceUrl = process.env['WORKSPACE_URL']; - if (!workspaceUrl) throw new Error('WORKSPACE_URL is required'); + const env = loadEnv(); const projectRoot = path.join(import.meta.dirname, '../..'); const fn = new lambda.NodejsFunction(this, 'SlackHandler', { - entry: path.join(projectRoot, 'index.ts'), + entry: path.join(projectRoot, 'src/handler.ts'), handler: 'handler', runtime: runtime.Runtime.NODEJS_24_X, timeout: cdk.Duration.seconds(30), environment: { - SLACK_BOT_TOKEN: slackBotToken, - SLACK_SIGNING_SECRET: slackSigningSecret, - TIMES_ALL_CHANNEL_ID: timesAllChannelId, - WORKSPACE_URL: workspaceUrl, + SLACK_BOT_TOKEN: env.slackBotToken, + SLACK_SIGNING_SECRET: env.slackSigningSecret, + TIMES_ALL_CHANNEL_ID: env.timesAllChannelId, + WORKSPACE_URL: env.workspaceUrl, }, bundling: { nodeModules: ['@slack/bolt'], diff --git a/infra/package-lock.json b/infra/package-lock.json index 9da2a37..56bc11e 100644 --- a/infra/package-lock.json +++ b/infra/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "dependencies": { "aws-cdk-lib": "^2.245.0", - "constructs": "^10.6.0" + "constructs": "^10.6.0", + "valibot": "^1.3.1" }, "devDependencies": { "@tsconfig/strictest": "^2.0.8", @@ -984,7 +985,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1000,6 +1001,20 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" + }, + "node_modules/valibot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", + "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } } } } diff --git a/infra/package.json b/infra/package.json index 12f8f0b..9ac596e 100644 --- a/infra/package.json +++ b/infra/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "aws-cdk-lib": "^2.245.0", - "constructs": "^10.6.0" + "constructs": "^10.6.0", + "valibot": "^1.3.1" }, "devDependencies": { "@tsconfig/strictest": "^2.0.8", diff --git a/infra/tsconfig.json b/infra/tsconfig.json index ed82f00..2989dd3 100644 --- a/infra/tsconfig.json +++ b/infra/tsconfig.json @@ -9,5 +9,5 @@ "types": ["node"], "resolveJsonModule": true }, - "include": ["bin/**/*.ts", "lib/**/*.ts"] + "include": ["bin/**/*.ts", "lib/**/*.ts", "../src/env.ts"] } diff --git a/joinBotToTimesChannels.ts b/joinBotToTimesChannels.ts deleted file mode 100644 index 4be8d3a..0000000 --- a/joinBotToTimesChannels.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { WebClient } from '@slack/web-api'; - -const token = process.env['SLACK_BOT_TOKEN']; // .envからBotトークンを取得 -const web = new WebClient(token); - -async function joinTimesChannels() { - try { - // パブリックチャンネルのリストを取得 - const result = await web.conversations.list({ - types: 'public_channel', - limit: 1000, - }); - const channels = result.channels; - console.log(channels?.length); // チャンネルの情報を表示 - if (!channels) throw new Error('channels is undefined'); - // `times-` プレフィックスがついたチャンネルにBotを追加 - for (const channel of channels) { - if (channel.name?.startsWith('times-') && channel.is_archived === false) { - console.log(channel.name); - if (!channel.id) throw new Error('channel id is undefined'); - await web.conversations.join({ channel: channel.id }); - console.log(`Joined channel: ${channel.name}`); - } - } - } catch (error) { - console.error(error); - } -} - -joinTimesChannels(); diff --git a/package-lock.json b/package-lock.json index 2efeb2e..204eb19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@slack/bolt": "^4.6.0" + "@slack/bolt": "^4.6.0", + "valibot": "^1.3.1" }, "devDependencies": { "@biomejs/biome": "2.4.9", @@ -2074,7 +2075,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2099,6 +2100,20 @@ "node": ">= 0.8" } }, + "node_modules/valibot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", + "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index e2dc758..58ea13c 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,19 @@ "description": "repost times-* channel to \"time-all\"", "type": "module", "scripts": { - "dev": "node --env-file=.env index.ts", + "dev": "node --env-file=.env src/handler.ts", "lint": "biome check --write .", "format": "biome format .", "check": "biome check .", - "typecheck": "tsc --noEmit" + "typecheck": "tsc", + "test": "node --test 'src/**/*.test.ts'" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { - "@slack/bolt": "^4.6.0" + "@slack/bolt": "^4.6.0", + "valibot": "^1.3.1" }, "devDependencies": { "@biomejs/biome": "2.4.9", diff --git a/src/__tests__/env.test.ts b/src/__tests__/env.test.ts new file mode 100644 index 0000000..68cdb13 --- /dev/null +++ b/src/__tests__/env.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import * as v from 'valibot'; +import { loadEnv } from '../env.ts'; + +describe('loadEnv', () => { + const validEnv = { + SLACK_SIGNING_SECRET: 'secret', + SLACK_BOT_TOKEN: 'xoxb-token', + TIMES_ALL_CHANNEL_ID: 'C12345', + WORKSPACE_URL: 'https://workspace.slack.com', + }; + + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = {}; + for (const key of Object.keys(validEnv)) { + originalEnv[key] = process.env[key]; + } + for (const [key, value] of Object.entries(validEnv)) { + process.env[key] = value; + } + }); + + afterEach(() => { + for (const key of Object.keys(validEnv)) { + const original = originalEnv[key]; + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } + } + }); + + it('すべての環境変数が設定されている場合はEnvオブジェクトを返す', () => { + const env = loadEnv(); + assert.deepEqual(env, { + slackSigningSecret: 'secret', + slackBotToken: 'xoxb-token', + timesAllChannelId: 'C12345', + workspaceUrl: 'https://workspace.slack.com', + }); + }); + + it('SLACK_SIGNING_SECRETが未設定の場合はValiErrorを投げる', () => { + delete process.env['SLACK_SIGNING_SECRET']; + assert.throws(() => loadEnv(), v.ValiError); + }); + + it('SLACK_BOT_TOKENが未設定の場合はValiErrorを投げる', () => { + delete process.env['SLACK_BOT_TOKEN']; + assert.throws(() => loadEnv(), v.ValiError); + }); + + it('TIMES_ALL_CHANNEL_IDが未設定の場合はValiErrorを投げる', () => { + delete process.env['TIMES_ALL_CHANNEL_ID']; + assert.throws(() => loadEnv(), v.ValiError); + }); + + it('WORKSPACE_URLが未設定の場合はValiErrorを投げる', () => { + delete process.env['WORKSPACE_URL']; + assert.throws(() => loadEnv(), v.ValiError); + }); + + it('WORKSPACE_URLが不正なURLの場合はValiErrorを投げる', () => { + process.env['WORKSPACE_URL'] = 'not-a-url'; + assert.throws(() => loadEnv(), v.ValiError); + }); + + it('WORKSPACE_URLが末尾スラッシュを含む場合はValiErrorを投げる', () => { + process.env['WORKSPACE_URL'] = 'https://workspace.slack.com/'; + assert.throws(() => loadEnv(), v.ValiError); + }); +}); diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..8859e9c --- /dev/null +++ b/src/app.ts @@ -0,0 +1,25 @@ +import { App, AwsLambdaReceiver } from '@slack/bolt'; +import type { Env } from './env.ts'; +import { handleChannelCreated } from './handlers/channelCreated.ts'; +import { createMessageHandler } from './handlers/message.ts'; + +export interface SlackApp { + app: App; + receiver: AwsLambdaReceiver; +} + +export function createSlackApp(env: Env): SlackApp { + const receiver = new AwsLambdaReceiver({ + signingSecret: env.slackSigningSecret, + }); + + const app = new App({ + token: env.slackBotToken, + receiver, + }); + + app.message(createMessageHandler(env.timesAllChannelId, env.workspaceUrl)); + app.event('channel_created', handleChannelCreated); + + return { app, receiver }; +} diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..3bdc38d --- /dev/null +++ b/src/env.ts @@ -0,0 +1,35 @@ +import * as v from 'valibot'; + +const EnvSchema = v.object({ + SLACK_SIGNING_SECRET: v.pipe(v.string(), v.nonEmpty()), + SLACK_BOT_TOKEN: v.pipe(v.string(), v.nonEmpty()), + TIMES_ALL_CHANNEL_ID: v.pipe(v.string(), v.nonEmpty()), + WORKSPACE_URL: v.pipe( + v.string(), + v.url(), + v.check((s) => !s.endsWith('/'), 'URL must not end with a trailing slash'), + ), +}); + +export type Env = { + slackSigningSecret: string; + slackBotToken: string; + timesAllChannelId: string; + workspaceUrl: string; +}; + +export function loadEnv(): Env { + const parsed = v.parse(EnvSchema, { + SLACK_SIGNING_SECRET: process.env['SLACK_SIGNING_SECRET'], + SLACK_BOT_TOKEN: process.env['SLACK_BOT_TOKEN'], + TIMES_ALL_CHANNEL_ID: process.env['TIMES_ALL_CHANNEL_ID'], + WORKSPACE_URL: process.env['WORKSPACE_URL'], + }); + + return { + slackSigningSecret: parsed.SLACK_SIGNING_SECRET, + slackBotToken: parsed.SLACK_BOT_TOKEN, + timesAllChannelId: parsed.TIMES_ALL_CHANNEL_ID, + workspaceUrl: parsed.WORKSPACE_URL, + }; +} diff --git a/src/handler.ts b/src/handler.ts new file mode 100644 index 0000000..a893b28 --- /dev/null +++ b/src/handler.ts @@ -0,0 +1,19 @@ +import { createSlackApp } from './app.ts'; +import { loadEnv } from './env.ts'; + +const env = loadEnv(); +const { receiver } = createSlackApp(env); + +// Lambdaのイベント処理 +// Node.js 24ではcallbackベースのハンドラーが廃止されたため、 +// AwsLambdaReceiverのハンドラーを2引数のasync関数でラップする +// ref: https://github.com/slackapi/bolt-js/issues/2761 +export const handler = async ( + event: Record, + context: unknown, +) => { + const boltHandler = await receiver.start(); + // boltHandlerは内部的にcallbackを使用しないが、型定義上3引数が必須 + // biome-ignore lint/suspicious/noExplicitAny: AwsEventの型がexportされていないため + return boltHandler(event as any, context, () => {}); +}; diff --git a/src/handlers/__tests__/channelCreated.test.ts b/src/handlers/__tests__/channelCreated.test.ts new file mode 100644 index 0000000..fadd404 --- /dev/null +++ b/src/handlers/__tests__/channelCreated.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; +import { handleChannelCreated } from '../channelCreated.ts'; + +describe('handleChannelCreated', () => { + function makeArgs(channelName: string, channelId: string) { + return { + event: { + channel: { name: channelName, id: channelId }, + }, + client: { + conversations: { + join: mock.fn(() => Promise.resolve()), + }, + }, + logger: { + debug: mock.fn(), + info: mock.fn(), + }, + }; + } + + it('times-で始まるチャンネルにjoinする', async () => { + // biome-ignore lint/suspicious/noExplicitAny: テスト用のモック + const args = makeArgs('times-yamada', 'C12345') as any; + await handleChannelCreated(args); + + assert.equal(args.client.conversations.join.mock.callCount(), 1); + assert.deepEqual( + args.client.conversations.join.mock.calls[0].arguments[0], + { channel: 'C12345' }, + ); + }); + + it('times-以外のチャンネルにはjoinしない', async () => { + // biome-ignore lint/suspicious/noExplicitAny: テスト用のモック + const args = makeArgs('general', 'C12345') as any; + await handleChannelCreated(args); + + assert.equal(args.client.conversations.join.mock.callCount(), 0); + }); +}); diff --git a/src/handlers/__tests__/message.test.ts b/src/handlers/__tests__/message.test.ts new file mode 100644 index 0000000..56606c2 --- /dev/null +++ b/src/handlers/__tests__/message.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; +import { createMessageHandler } from '../message.ts'; + +describe('createMessageHandler', () => { + const timesAllChannelId = 'C_TIMES_ALL'; + const workspaceUrl = 'https://workspace.slack.com'; + const handler = createMessageHandler(timesAllChannelId, workspaceUrl); + + function makeArgs( + message: Record, + channelName: string | undefined = 'times-yamada', + ) { + return { + message, + client: { + conversations: { + info: mock.fn(() => + Promise.resolve({ + channel: { name: channelName }, + }), + ), + }, + chat: { + postMessage: mock.fn(() => Promise.resolve()), + }, + }, + }; + } + + it('timesチャンネルのメッセージをtimes-allに転送する', async () => { + const message = { + type: 'message', + subtype: undefined, + channel: 'C_TIMES_YAMADA', + channel_type: 'channel', + text: 'hello', + ts: '1234567890.123456', + user: 'U12345', + }; + // biome-ignore lint/suspicious/noExplicitAny: テスト用のモック + const args = makeArgs(message) as any; + await handler(args); + + assert.equal(args.client.chat.postMessage.mock.callCount(), 1); + const call = args.client.chat.postMessage.mock.calls[0]; + assert.equal(call.arguments[0].channel, timesAllChannelId); + assert.ok( + (call.arguments[0].text as string).includes( + 'https://workspace.slack.com/archives/C_TIMES_YAMADA/p1234567890123456', + ), + ); + }); + + it('times-以外のチャンネルは転送しない', async () => { + const message = { + type: 'message', + subtype: undefined, + channel: 'C_GENERAL', + channel_type: 'channel', + text: 'hello', + ts: '1234567890.123456', + user: 'U12345', + }; + // biome-ignore lint/suspicious/noExplicitAny: テスト用のモック + const args = makeArgs(message, 'general') as any; + await handler(args); + + assert.equal(args.client.chat.postMessage.mock.callCount(), 0); + }); + + it('subtypeを持つメッセージは転送しない', async () => { + const message = { + type: 'message', + subtype: 'channel_join', + channel: 'C_TIMES_YAMADA', + channel_type: 'channel', + ts: '1234567890.123456', + }; + // biome-ignore lint/suspicious/noExplicitAny: テスト用のモック + const args = makeArgs(message) as any; + await handler(args); + + assert.equal(args.client.conversations.info.mock.callCount(), 0); + assert.equal(args.client.chat.postMessage.mock.callCount(), 0); + }); + + it('DMメッセージは転送しない', async () => { + const message = { + type: 'message', + subtype: undefined, + channel: 'C_TIMES_YAMADA', + channel_type: 'im', + text: 'hello', + ts: '1234567890.123456', + user: 'U12345', + }; + // biome-ignore lint/suspicious/noExplicitAny: テスト用のモック + const args = makeArgs(message) as any; + await handler(args); + + assert.equal(args.client.conversations.info.mock.callCount(), 0); + assert.equal(args.client.chat.postMessage.mock.callCount(), 0); + }); +}); diff --git a/src/handlers/channelCreated.ts b/src/handlers/channelCreated.ts new file mode 100644 index 0000000..ea15fea --- /dev/null +++ b/src/handlers/channelCreated.ts @@ -0,0 +1,16 @@ +import type { AllMiddlewareArgs, SlackEventMiddlewareArgs } from '@slack/bolt'; +import { isTimesChannel } from '../lib/timesChannel.ts'; + +export async function handleChannelCreated({ + event, + client, + logger, +}: AllMiddlewareArgs & SlackEventMiddlewareArgs<'channel_created'>) { + const { name, id } = event.channel; + if (!name || !isTimesChannel(name)) { + return; + } + logger.debug(name); + await client.conversations.join({ channel: id }); + logger.info(`Joined channel: ${name}`); +} diff --git a/src/handlers/message.ts b/src/handlers/message.ts new file mode 100644 index 0000000..7470ce7 --- /dev/null +++ b/src/handlers/message.ts @@ -0,0 +1,42 @@ +import type { AllMiddlewareArgs, SlackEventMiddlewareArgs } from '@slack/bolt'; +import { buildForwardText } from '../lib/forwardText.ts'; +import { isForwardableMessage } from '../lib/messageFilter.ts'; +import { buildMessageLink } from '../lib/messageLink.ts'; +import { isTimesChannel } from '../lib/timesChannel.ts'; + +export function createMessageHandler( + timesAllChannelId: string, + workspaceUrl: string, +) { + return async ({ + message, + client, + }: AllMiddlewareArgs & SlackEventMiddlewareArgs<'message'>) => { + console.log('received message'); + if (!isForwardableMessage(message, timesAllChannelId)) { + return; + } + + const channelInfo = await client.conversations.info({ + channel: message.channel, + }); + const channelName = channelInfo.channel?.name; + if (!channelName || !isTimesChannel(channelName)) { + console.log(channelName); + return; + } + + console.log('channel name starts with times-'); + const messageLink = buildMessageLink( + workspaceUrl, + message.channel, + message.ts, + ); + console.log('messageLink:', messageLink); + const text = buildForwardText(messageLink, message.channel); + await client.chat.postMessage({ + channel: timesAllChannelId, + text, + }); + }; +} diff --git a/src/joinBotToTimesChannels.ts b/src/joinBotToTimesChannels.ts new file mode 100644 index 0000000..9043547 --- /dev/null +++ b/src/joinBotToTimesChannels.ts @@ -0,0 +1,47 @@ +import { WebClient } from '@slack/web-api'; +import { isTimesChannel } from './lib/timesChannel.ts'; + +const token = process.env['SLACK_BOT_TOKEN']; +if (!token) throw new Error('SLACK_BOT_TOKEN is undefined'); +const web = new WebClient(token); + +async function joinTimesChannels() { + let cursor: string | undefined; + + do { + const result = await web.conversations.list({ + types: 'public_channel', + limit: 1000, + ...(cursor ? { cursor } : {}), + }); + if (!result.ok) { + throw new Error( + `Failed to list conversations: ${result.error ?? 'unknown error'}`, + ); + } + const channels = result.channels; + if (!channels) { + throw new Error('channels is undefined in conversations.list response'); + } + if (channels.length === 0) break; + + console.log(`fetched ${channels.length} channels`); + + for (const channel of channels) { + if ( + channel.name && + isTimesChannel(channel.name) && + channel.is_archived === false + ) { + console.log(channel.name); + if (!channel.id) throw new Error('channel id is undefined'); + await web.conversations.join({ channel: channel.id }); + console.log(`Joined channel: ${channel.name}`); + } + } + + cursor = result.response_metadata?.next_cursor || undefined; + } while (cursor); +} + +await joinTimesChannels(); diff --git a/src/lib/__tests__/forwardText.test.ts b/src/lib/__tests__/forwardText.test.ts new file mode 100644 index 0000000..dca9325 --- /dev/null +++ b/src/lib/__tests__/forwardText.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { buildForwardText } from '../forwardText.ts'; + +describe('buildForwardText', () => { + it('Slackのメッセージリンクとチャンネルメンションを含むテキストを構築する', () => { + const result = buildForwardText( + 'https://workspace.slack.com/archives/C12345/p1234567890123456', + 'C12345', + ); + assert.equal( + result, + ' が <#C12345> で投稿されました。', + ); + }); +}); diff --git a/src/lib/__tests__/messageFilter.test.ts b/src/lib/__tests__/messageFilter.test.ts new file mode 100644 index 0000000..8ff12f9 --- /dev/null +++ b/src/lib/__tests__/messageFilter.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { AllMessageEvents, GenericMessageEvent } from '@slack/types'; +import { isForwardableMessage } from '../messageFilter.ts'; + +describe('isForwardableMessage', () => { + const timesAllChannelId = 'C_TIMES_ALL'; + + const baseMessage: GenericMessageEvent = { + type: 'message', + subtype: undefined, + channel: 'C_OTHER', + channel_type: 'channel', + text: 'hello', + ts: '1234567890.123456', + user: 'U12345', + } as GenericMessageEvent; + + it('通常のチャンネルメッセージはtrueを返す', () => { + assert.equal(isForwardableMessage(baseMessage, timesAllChannelId), true); + }); + + it('times-allチャンネルのメッセージはfalseを返す', () => { + const message = { ...baseMessage, channel: timesAllChannelId }; + assert.equal(isForwardableMessage(message, timesAllChannelId), false); + }); + + it('DMメッセージはfalseを返す', () => { + const message = { ...baseMessage, channel_type: 'im' as const }; + assert.equal(isForwardableMessage(message, timesAllChannelId), false); + }); + + it('subtypeを持つメッセージはfalseを返す (channel_join)', () => { + const message = { + type: 'message', + subtype: 'channel_join', + channel: 'C_OTHER', + channel_type: 'channel', + ts: '1234567890.123456', + } as AllMessageEvents; + assert.equal(isForwardableMessage(message, timesAllChannelId), false); + }); + + it('subtypeを持つメッセージはfalseを返す (bot_message)', () => { + const message = { + type: 'message', + subtype: 'bot_message', + channel: 'C_OTHER', + channel_type: 'channel', + ts: '1234567890.123456', + } as AllMessageEvents; + assert.equal(isForwardableMessage(message, timesAllChannelId), false); + }); + + it('subtypeを持つメッセージはfalseを返す (message_changed)', () => { + const message = { + type: 'message', + subtype: 'message_changed', + channel: 'C_OTHER', + channel_type: 'channel', + ts: '1234567890.123456', + hidden: true, + } as AllMessageEvents; + assert.equal(isForwardableMessage(message, timesAllChannelId), false); + }); +}); diff --git a/src/lib/__tests__/messageLink.test.ts b/src/lib/__tests__/messageLink.test.ts new file mode 100644 index 0000000..f6fb2a8 --- /dev/null +++ b/src/lib/__tests__/messageLink.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { buildMessageLink } from '../messageLink.ts'; + +describe('buildMessageLink', () => { + it('タイムスタンプのドットを除去してSlackのメッセージリンクを構築する', () => { + const result = buildMessageLink( + 'https://workspace.slack.com', + 'C12345', + '1234567890.123456', + ); + assert.equal( + result, + 'https://workspace.slack.com/archives/C12345/p1234567890123456', + ); + }); + + it('ドットのないタイムスタンプでも動作する', () => { + const result = buildMessageLink( + 'https://workspace.slack.com', + 'C12345', + '1234567890123456', + ); + assert.equal( + result, + 'https://workspace.slack.com/archives/C12345/p1234567890123456', + ); + }); +}); diff --git a/src/lib/__tests__/timesChannel.test.ts b/src/lib/__tests__/timesChannel.test.ts new file mode 100644 index 0000000..a3654cd --- /dev/null +++ b/src/lib/__tests__/timesChannel.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isTimesChannel } from '../timesChannel.ts'; + +describe('isTimesChannel', () => { + it('times-で始まるチャンネル名にtrueを返す', () => { + assert.equal(isTimesChannel('times-yamada'), true); + }); + + it('times-のみでもtrueを返す', () => { + assert.equal(isTimesChannel('times-'), true); + }); + + it('times-で始まらないチャンネル名にfalseを返す', () => { + assert.equal(isTimesChannel('general'), false); + }); + + it('timesだけではfalseを返す', () => { + assert.equal(isTimesChannel('times'), false); + }); + + it('空文字にfalseを返す', () => { + assert.equal(isTimesChannel(''), false); + }); +}); diff --git a/src/lib/forwardText.ts b/src/lib/forwardText.ts new file mode 100644 index 0000000..6247247 --- /dev/null +++ b/src/lib/forwardText.ts @@ -0,0 +1,6 @@ +export function buildForwardText( + messageLink: string, + channelId: string, +): string { + return `<${messageLink}|このメッセージ> が <#${channelId}> で投稿されました。`; +} diff --git a/src/lib/messageFilter.ts b/src/lib/messageFilter.ts new file mode 100644 index 0000000..a8b2741 --- /dev/null +++ b/src/lib/messageFilter.ts @@ -0,0 +1,16 @@ +import type { AllMessageEvents } from '@slack/types'; + +/** + * 転送対象のメッセージかどうかを判定する。 + * subtypeを持つメッセージ(channel_join, message_changed, bot_message等)は + * すべて転送対象外とする。hidden判定も不要(hidden: trueを持つのはsubtype付きのみ)。 + */ +export function isForwardableMessage( + message: AllMessageEvents, + timesAllChannelId: string, +): boolean { + if (message.subtype !== undefined) return false; + if (message.channel_type !== 'channel') return false; + if (message.channel === timesAllChannelId) return false; + return true; +} diff --git a/src/lib/messageLink.ts b/src/lib/messageLink.ts new file mode 100644 index 0000000..80c602e --- /dev/null +++ b/src/lib/messageLink.ts @@ -0,0 +1,8 @@ +export function buildMessageLink( + workspaceUrl: string, + channelId: string, + ts: string, +): string { + const timestamp = ts.replace('.', ''); + return `${workspaceUrl}/archives/${channelId}/p${timestamp}`; +} diff --git a/src/lib/timesChannel.ts b/src/lib/timesChannel.ts new file mode 100644 index 0000000..37a0c77 --- /dev/null +++ b/src/lib/timesChannel.ts @@ -0,0 +1,5 @@ +const TIMES_PREFIX = 'times-'; + +export function isTimesChannel(name: string): boolean { + return name.startsWith(TIMES_PREFIX); +} diff --git a/tsconfig.json b/tsconfig.json index d05d7b6..fb672c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,9 @@ "compilerOptions": { "target": "es2022", "module": "node16", - "types": ["node"] + "types": ["node"], + "allowImportingTsExtensions": true, + "noEmit": true }, "exclude": ["infra"] }