@book000/node-utils 1.2.28 → 1.2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/discord.ts ADDED
@@ -0,0 +1,219 @@
1
+ import axios from 'axios'
2
+ import FormData from 'form-data'
3
+
4
+ interface DiscordBotOptions {
5
+ token: string
6
+ channelId: string
7
+ }
8
+
9
+ interface DiscordWebhookOptions {
10
+ webhookUrl: string
11
+ }
12
+
13
+ export type DiscordOptions = DiscordBotOptions | DiscordWebhookOptions
14
+ export interface DiscordEmbedFooter {
15
+ text: string
16
+ icon_url?: string
17
+ proxy_icon_url?: string
18
+ }
19
+
20
+ export interface DiscordEmbedImage {
21
+ url?: string
22
+ proxy_url?: string
23
+ height?: number
24
+ width?: number
25
+ }
26
+
27
+ export interface DiscordEmbedThumbnail {
28
+ url?: string
29
+ proxy_url?: string
30
+ height?: number
31
+ width?: number
32
+ }
33
+
34
+ export interface DiscordEmbedVideo {
35
+ url?: string
36
+ proxy_url?: string
37
+ height?: number
38
+ width?: number
39
+ }
40
+
41
+ export interface DiscordEmbedProvider {
42
+ name?: string
43
+ url?: string
44
+ }
45
+
46
+ export interface DiscordEmbedAuthor {
47
+ name?: string
48
+ url?: string
49
+ icon_url?: string
50
+ proxy_icon_url?: string
51
+ }
52
+
53
+ export interface DiscordEmbedField {
54
+ name: string
55
+ value: string
56
+ inline?: boolean
57
+ }
58
+
59
+ export interface DiscordEmbed {
60
+ title?: string
61
+ type?: 'rich' | 'image' | 'video' | 'gifv' | 'article' | 'link'
62
+ description?: string
63
+ url?: string
64
+ timestamp?: string
65
+ color?: number
66
+ footer?: DiscordEmbedFooter
67
+ image?: DiscordEmbedImage
68
+ thumbnail?: DiscordEmbedThumbnail
69
+ video?: DiscordEmbedVideo
70
+ provider?: DiscordEmbedProvider
71
+ author?: DiscordEmbedAuthor
72
+ fields?: DiscordEmbedField[]
73
+ }
74
+
75
+ interface DiscordNormalMessage {
76
+ content: string
77
+ }
78
+
79
+ interface DiscordEmbedMessage {
80
+ embeds: DiscordEmbed[]
81
+ }
82
+
83
+ interface DiscordFile {
84
+ name: string
85
+ file: ArrayBuffer
86
+ contentType?: string
87
+ isSpoiler?: boolean
88
+ }
89
+
90
+ interface DiscordFileMessage {
91
+ file: DiscordFile
92
+ }
93
+
94
+ export type DiscordMessage =
95
+ | DiscordNormalMessage
96
+ | DiscordEmbedMessage
97
+ | DiscordFileMessage
98
+
99
+ export class Discord {
100
+ private options: DiscordOptions
101
+
102
+ constructor(options: DiscordOptions) {
103
+ // token があれば Bot として動作する
104
+ // webhookUrl と channelId があれば Webhook として動作する
105
+ // どちらもなければエラーを投げる
106
+
107
+ if (this.isDiscordBotOptions(options)) {
108
+ this.options = options
109
+ } else if (this.isDiscordWebhookOptions(options)) {
110
+ this.options = options
111
+ } else {
112
+ throw new Error('Invalid options')
113
+ }
114
+ }
115
+
116
+ public static get validations(): {
117
+ [key: string]: (options: any) => boolean
118
+ } {
119
+ return {
120
+ 'token or webhookUrl and channelId': (options: any) =>
121
+ 'token' in options ||
122
+ ('webhookUrl' in options && 'channelId' in options),
123
+ 'token is valid': (options: any) => typeof options.token === 'string',
124
+ 'webhookUrl is valid': (options: any) =>
125
+ typeof options.webhookUrl === 'string',
126
+ 'channelId is valid': (options: any) =>
127
+ typeof options.channelId === 'string',
128
+ }
129
+ }
130
+
131
+ public async sendMessage(message: string | DiscordMessage): Promise<void> {
132
+ const formData = new FormData()
133
+
134
+ if (typeof message === 'string') {
135
+ formData.append('payload_json', JSON.stringify({ content: message }))
136
+ } else {
137
+ formData.append(
138
+ 'payload_json',
139
+ JSON.stringify({
140
+ content: 'content' in message ? message.content : undefined,
141
+ embeds: 'embeds' in message ? message.embeds : undefined,
142
+ })
143
+ )
144
+
145
+ if ('file' in message) {
146
+ formData.append('file', message.file.file, {
147
+ filename: `${message.file.isSpoiler === true ? 'SPOILER_' : ''}${
148
+ message.file.name
149
+ }`,
150
+ contentType: message.file.contentType,
151
+ })
152
+ }
153
+ }
154
+
155
+ await (this.isDiscordBotOptions(this.options)
156
+ ? this.sendBot(formData)
157
+ : this.sendWebhook(formData))
158
+ }
159
+
160
+ private async sendBot(formData: FormData): Promise<void> {
161
+ if (!this.isDiscordBotOptions(this.options)) {
162
+ throw new Error('Invalid bot options')
163
+ }
164
+
165
+ const response = await axios.post(
166
+ `https://discord.com/api/channels/${this.options}/messages`,
167
+ formData,
168
+ {
169
+ headers: {
170
+ ...formData.getHeaders(),
171
+ Authorization: `Bot ${this.options.token}`,
172
+ },
173
+ validateStatus: () => true,
174
+ }
175
+ )
176
+ if (response.status !== 200) {
177
+ throw new Error(`Discord API returned ${response.status}`)
178
+ }
179
+ }
180
+
181
+ private async sendWebhook(formData: FormData): Promise<void> {
182
+ if (!this.isDiscordWebhookOptions(this.options)) {
183
+ throw new Error('Invalid webhook options')
184
+ }
185
+
186
+ const response = await axios.post(this.options.webhookUrl, formData, {
187
+ headers: {
188
+ ...formData.getHeaders(),
189
+ },
190
+ validateStatus: () => true,
191
+ })
192
+ if (response.status !== 200 && response.status !== 204) {
193
+ throw new Error(`Discord API returned ${response.status}`)
194
+ }
195
+ }
196
+
197
+ private isDiscordBotOptions(
198
+ options: DiscordOptions
199
+ ): options is DiscordBotOptions {
200
+ return (
201
+ 'token' in options &&
202
+ typeof options.token === 'string' &&
203
+ options.token.length > 0 &&
204
+ 'channelId' in options &&
205
+ typeof options.channelId === 'string' &&
206
+ options.channelId.length > 0
207
+ )
208
+ }
209
+
210
+ private isDiscordWebhookOptions(
211
+ options: DiscordOptions
212
+ ): options is DiscordWebhookOptions {
213
+ return (
214
+ 'webhookUrl' in options &&
215
+ typeof options.webhookUrl === 'string' &&
216
+ options.webhookUrl.length > 0
217
+ )
218
+ }
219
+ }
@@ -0,0 +1,33 @@
1
+ import { ConfigFramework, Logger } from '..'
2
+
3
+ export interface Configuration {
4
+ foo: string
5
+ bar: number
6
+ }
7
+
8
+ class ExampleConfiguration extends ConfigFramework<Configuration> {
9
+ protected validates(): { [key: string]: (config: Configuration) => boolean } {
10
+ return {
11
+ // ...Discord.validations, // When using a message transmission to Discord
12
+ 'foo is required': (config) => config.foo !== undefined,
13
+ 'foo is string': (config) => typeof config.foo === 'string',
14
+ 'foo is 3 or more characters': (config) => config.foo.length >= 3,
15
+ 'bar is required': (config) => config.bar !== undefined,
16
+ 'bar is number': (config) => typeof config.bar === 'number',
17
+ }
18
+ }
19
+ }
20
+
21
+ export function exampleConfiguration() {
22
+ const logger = Logger.configure('exampleConfiguration')
23
+ const config = new ExampleConfiguration()
24
+ config.load()
25
+ if (!config.validate()) {
26
+ logger.error('Configuration validation failed')
27
+ logger.error(config.getValidateFailures().join(', '))
28
+ return
29
+ }
30
+
31
+ logger.info(`foo: ${config.get('foo')}`)
32
+ logger.info(`bar: ${config.get('bar')}`)
33
+ }
@@ -0,0 +1,28 @@
1
+ import { Discord, Logger } from '..'
2
+
3
+ export async function exampleDiscord() {
4
+ const logger = Logger.configure('exampleDiscord')
5
+
6
+ const discordWebhookUrl = process.env.DISCORD_WEBHOOK_URL
7
+ if (!discordWebhookUrl) {
8
+ logger.error('DISCORD_WEBHOOK_URL are required')
9
+ return
10
+ }
11
+
12
+ const discord = new Discord({
13
+ webhookUrl: discordWebhookUrl,
14
+ })
15
+
16
+ await discord.sendMessage('Hello world!')
17
+
18
+ await discord.sendMessage({
19
+ embeds: [
20
+ {
21
+ title: 'Hello world!',
22
+ description:
23
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
24
+ color: 0x00_ff_00,
25
+ },
26
+ ],
27
+ })
28
+ }
@@ -0,0 +1,6 @@
1
+ import { Logger } from '..'
2
+
3
+ export function exampleLogger() {
4
+ const logger = Logger.configure('exampleLogger')
5
+ logger.info('Hello world!')
6
+ }
@@ -0,0 +1,38 @@
1
+ import { Logger } from '@/logger'
2
+ import { Configuration, exampleConfiguration } from './example-configuration'
3
+ import { exampleLogger } from './example-logger'
4
+ import { exampleDiscord } from './example-discord'
5
+ import fs from 'node:fs'
6
+ import os from 'node:os'
7
+
8
+ async function main() {
9
+ const logger = Logger.configure('main')
10
+ logger.info('Running main()')
11
+
12
+ logger.info('Calling exampleLogger()')
13
+ exampleLogger()
14
+
15
+ logger.info('Create dummy configuration file')
16
+ const config: Configuration = {
17
+ foo: 'foo',
18
+ bar: 123,
19
+ }
20
+ const temporaryConfigPath = `${os.tmpdir()}/config.json`
21
+ fs.writeFileSync(temporaryConfigPath, JSON.stringify(config))
22
+
23
+ logger.info('Set environment variable')
24
+ process.env.CONFIG_PATH = temporaryConfigPath
25
+
26
+ logger.info('Calling exampleConfiguration()')
27
+ exampleConfiguration()
28
+
29
+ logger.info('Remove dummy configuration file')
30
+ fs.unlinkSync(temporaryConfigPath)
31
+
32
+ logger.info('Calling exampleDiscord()')
33
+ await exampleDiscord()
34
+ }
35
+
36
+ ;(async () => {
37
+ await main()
38
+ })()
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './configuration'
2
+ export * from './discord'
3
+ export * from './logger'
package/src/logger.ts ADDED
@@ -0,0 +1,168 @@
1
+ import winston, { format } from 'winston'
2
+ import WinstonDailyRotateFile from 'winston-daily-rotate-file'
3
+ import { Format } from 'logform'
4
+ import cycle from 'cycle'
5
+
6
+ /**
7
+ * ロガーラッパークラス
8
+ */
9
+ export class Logger {
10
+ private readonly logger: winston.Logger
11
+
12
+ private constructor(logger: winston.Logger) {
13
+ this.logger = logger
14
+ }
15
+
16
+ /**
17
+ * デバッグログを出力する
18
+ *
19
+ * @param message メッセージ
20
+ * @param metadata メタデータ
21
+ */
22
+ public debug(message: string, metadata?: Record<string, unknown>): void {
23
+ this.logger.debug(message, metadata || {})
24
+ }
25
+
26
+ /**
27
+ * 情報ログを出力する
28
+ *
29
+ * @param message メッセージ
30
+ * @param metadata メタデータ
31
+ */
32
+ public info(message: string, metadata?: Record<string, unknown>): void {
33
+ this.logger.info(message, metadata || {})
34
+ }
35
+
36
+ /**
37
+ * 警告ログを出力する
38
+ *
39
+ * @param message メッセージ
40
+ * @param error エラー
41
+ */
42
+ public warn(message: string, error?: Error): void {
43
+ this.logger.warn(message, error)
44
+ }
45
+
46
+ /**
47
+ * エラーログを出力する
48
+ *
49
+ * @param message メッセージ
50
+ * @param error エラー
51
+ */
52
+ public error(message: string, error?: Error): void {
53
+ this.logger.error(message, error)
54
+ }
55
+
56
+ /**
57
+ * ロガーを初期化・設定する
58
+ *
59
+ * 環境変数で以下の設定が可能
60
+ * - LOG_LEVEL: ログレベル (デフォルト info)
61
+ * - LOG_FILE_LEVEL: ファイル出力のログレベル (デフォルト info)
62
+ * - LOG_DIR: ログ出力先 (デフォルト logs)
63
+ * - LOG_FILE_MAX_AGE: ログファイルの最大保存期間 (デフォルト 30d)
64
+ * - LOG_FILE_FORMAT: ログファイルのフォーマット (デフォルト text)
65
+ *
66
+ * @param category カテゴリ
67
+ * @returns ロガー
68
+ */
69
+ public static configure(category: string): Logger {
70
+ const logLevel = process.env.LOG_LEVEL || 'info'
71
+ const logFileLevel = process.env.LOG_FILE_LEVEL || 'info'
72
+ const logDirectory = process.env.LOG_DIR || 'logs'
73
+ const logFileMaxAge = process.env.LOG_FILE_MAX_AGE || '30d'
74
+ const selectLogFileFormat = process.env.LOG_FILE_FORMAT || 'text'
75
+
76
+ const textFormat = format.printf((info) => {
77
+ const { timestamp, level, message, ...rest } = info
78
+ // eslint-disable-next-line unicorn/no-array-reduce
79
+ const filteredRest = Object.keys(rest).reduce((accumulator, key) => {
80
+ if (key === 'stack') {
81
+ return accumulator
82
+ }
83
+ return {
84
+ ...accumulator,
85
+ [key]: rest[key],
86
+ }
87
+ }, {})
88
+ const standardLine = [
89
+ '[',
90
+ timestamp,
91
+ '] [',
92
+ category ?? '',
93
+ category ? '/' : '',
94
+ level.toLocaleUpperCase(),
95
+ ']: ',
96
+ message,
97
+ Object.keys(filteredRest).length > 0
98
+ ? ` (${JSON.stringify(filteredRest)})`
99
+ : '',
100
+ ].join('')
101
+ const errorLine = info.stack
102
+ ? info.stack.split('\n').slice(1).join('\n')
103
+ : undefined
104
+
105
+ return [standardLine, errorLine].filter((l) => l !== undefined).join('\n')
106
+ })
107
+ const logFileFormat =
108
+ selectLogFileFormat === 'ndjson' ? format.json() : textFormat
109
+ const decycleFormat = format((info) => cycle.decycle(info))
110
+ const fileFormat = format.combine(
111
+ ...([
112
+ format.errors({ stack: true }),
113
+ selectLogFileFormat === 'ndjson'
114
+ ? format.colorize({
115
+ message: true,
116
+ })
117
+ : format.uncolorize(),
118
+ decycleFormat(),
119
+ format.timestamp({
120
+ format: 'YYYY-MM-DD hh:mm:ss.SSS',
121
+ }),
122
+ logFileFormat,
123
+ ].filter((f) => f !== undefined) as Format[])
124
+ )
125
+ const consoleFormat = format.combine(
126
+ ...([
127
+ format.colorize({
128
+ message: true,
129
+ }),
130
+ decycleFormat(),
131
+ format.timestamp({
132
+ format: 'YYYY-MM-DD hh:mm:ss.SSS',
133
+ }),
134
+ textFormat,
135
+ ].filter((f) => f !== undefined) as Format[])
136
+ )
137
+ const extension = selectLogFileFormat === 'ndjson' ? 'ndjson' : 'log'
138
+ const transportRotateFile = new WinstonDailyRotateFile({
139
+ level: logFileLevel,
140
+ dirname: logDirectory,
141
+ filename: `%DATE%.` + extension,
142
+ datePattern: 'YYYY-MM-DD',
143
+ maxFiles: logFileMaxAge,
144
+ format: fileFormat,
145
+ auditFile: `${logDirectory}/audit.json`,
146
+ })
147
+
148
+ const logger = winston.createLogger({
149
+ transports: [
150
+ new winston.transports.Console({
151
+ level: logLevel,
152
+ format: consoleFormat,
153
+ }),
154
+ transportRotateFile,
155
+ ],
156
+ })
157
+ return new Logger(logger)
158
+ }
159
+ }
160
+
161
+ process.on('unhandledRejection', (reason) => {
162
+ const logger = Logger.configure('main')
163
+ logger.error('unhandledRejection', reason as Error)
164
+ })
165
+ process.on('uncaughtException', (error) => {
166
+ const logger = Logger.configure('main')
167
+ logger.error('uncaughtException', error)
168
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "ts-node": { "files": true },
3
+ "compilerOptions": {
4
+ "target": "es2020",
5
+ "module": "commonjs",
6
+ "moduleResolution": "Node",
7
+ "lib": ["ESNext", "esnext.AsyncIterable"],
8
+ "outDir": "./dist",
9
+ "removeComments": true,
10
+ "esModuleInterop": true,
11
+ "allowJs": true,
12
+ "checkJs": true,
13
+ "incremental": true,
14
+ "sourceMap": true,
15
+ "declaration": true,
16
+ "declarationMap": true,
17
+ "strict": true,
18
+ "noImplicitAny": true,
19
+ "strictBindCallApply": true,
20
+ "noUnusedLocals": true,
21
+ "noUnusedParameters": true,
22
+ "noImplicitReturns": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "experimentalDecorators": true,
25
+ "baseUrl": ".",
26
+ "newLine": "LF",
27
+ "paths": {
28
+ "@/*": ["src/*"]
29
+ }
30
+ },
31
+ "include": ["src/**/*"]
32
+ }