1 | import {
|
2 | _omit,
|
3 | AnyObject,
|
4 | CommonLogger,
|
5 | commonLoggerMinLevel,
|
6 | CommonLogLevel,
|
7 | localTime,
|
8 | PQueue,
|
9 | } from '@naturalcycles/js-lib'
|
10 | import got from 'got'
|
11 | import { inspectAny, InspectAnyOptions } from '..'
|
12 | import {
|
13 | SlackApiBody,
|
14 | SlackAttachmentField,
|
15 | SlackMessage,
|
16 | SlackServiceCfg,
|
17 | } from './slack.service.model'
|
18 |
|
19 | const GAE = !!process.env['GAE_INSTANCE']
|
20 |
|
21 | const DEFAULTS: SlackMessage = {
|
22 | username: 'bot',
|
23 | channel: '#log',
|
24 | icon_emoji: ':spider_web:',
|
25 | items: 'no text',
|
26 | }
|
27 |
|
28 | const INSPECT_OPT: InspectAnyOptions = {
|
29 | colors: false,
|
30 | includeErrorData: true,
|
31 | includeErrorStack: true,
|
32 | }
|
33 |
|
34 |
|
35 |
|
36 |
|
37 |
|
38 |
|
39 |
|
40 |
|
41 |
|
42 |
|
43 |
|
44 |
|
45 |
|
46 | export class SlackService<CTX = any> {
|
47 | constructor(cfg: Partial<SlackServiceCfg<CTX>>) {
|
48 | this.cfg = {
|
49 | messagePrefixHook: slackDefaultMessagePrefixHook,
|
50 | logger: console,
|
51 | ...cfg,
|
52 | inspectOptions: {
|
53 | ...INSPECT_OPT,
|
54 | ...cfg.inspectOptions,
|
55 | },
|
56 | }
|
57 | }
|
58 |
|
59 | public cfg!: SlackServiceCfg<CTX>
|
60 |
|
61 | |
62 |
|
63 |
|
64 | async log(...items: any[]): Promise<void> {
|
65 | await this.send({
|
66 |
|
67 | items: items.length === 1 ? items[0] : items,
|
68 | })
|
69 | }
|
70 |
|
71 | async send(input: SlackMessage<CTX> | string, ctx?: CTX): Promise<void> {
|
72 | const { webhookUrl, messagePrefixHook, inspectOptions } = this.cfg
|
73 |
|
74 |
|
75 | const msg = typeof input === 'string' ? { items: input } : input
|
76 |
|
77 | if (ctx !== undefined) {
|
78 | Object.assign(msg, { ctx })
|
79 | }
|
80 |
|
81 | this.cfg.logger.log(...[msg.items, msg.kv, msg.attachments, msg.mentions].filter(Boolean))
|
82 |
|
83 | if (!webhookUrl) return
|
84 |
|
85 |
|
86 | if (msg.kv) {
|
87 | ;(msg.attachments ||= []).push({ fields: this.kvToFields(msg.kv) })
|
88 |
|
89 | delete msg.kv
|
90 | }
|
91 |
|
92 | let text: string
|
93 |
|
94 |
|
95 | if (Array.isArray(msg.items)) {
|
96 | text = msg.items.map(t => inspectAny(t, inspectOptions)).join('\n')
|
97 | } else {
|
98 | text = inspectAny(msg.items, inspectOptions)
|
99 | }
|
100 |
|
101 |
|
102 | if (typeof msg.items !== 'string') {
|
103 | text = '```' + text + '```'
|
104 | }
|
105 |
|
106 | if (msg.mentions?.length) {
|
107 | text += '\n' + msg.mentions.map(s => `<@${s}>`).join(' ')
|
108 | }
|
109 |
|
110 | const prefix = await messagePrefixHook(msg)
|
111 | if (prefix === null) return
|
112 |
|
113 | const json: SlackApiBody = _omit(
|
114 | {
|
115 | ...DEFAULTS,
|
116 | ...this.cfg.defaults,
|
117 | ...msg,
|
118 |
|
119 | text: [prefix.join(': '), text].filter(Boolean).join('\n'),
|
120 | },
|
121 | ['items', 'ctx'],
|
122 | )
|
123 |
|
124 | await got
|
125 | .post(webhookUrl, {
|
126 | json,
|
127 | responseType: 'text',
|
128 | timeout: 90_000,
|
129 | })
|
130 | .catch(err => {
|
131 |
|
132 | if (msg.throwOnError) throw err
|
133 | })
|
134 | }
|
135 |
|
136 | kvToFields(kv: AnyObject): SlackAttachmentField[] {
|
137 | return Object.entries(kv).map(([k, v]) => ({
|
138 | title: k,
|
139 | value: String(v),
|
140 | short: String(v).length < 80,
|
141 | }))
|
142 | }
|
143 |
|
144 | |
145 |
|
146 |
|
147 | getCommonLogger(opt: {
|
148 | minLogLevel: CommonLogLevel
|
149 | logChannel?: string
|
150 | warnChannel?: string
|
151 | errorChannel?: string
|
152 | }): CommonLogger {
|
153 | const { minLogLevel = 'log', logChannel, warnChannel, errorChannel } = opt
|
154 | const defaultChannel = this.cfg.defaults?.channel || DEFAULTS.channel!
|
155 |
|
156 | const q = new PQueue({
|
157 | concurrency: 1,
|
158 | })
|
159 |
|
160 | return commonLoggerMinLevel(
|
161 | {
|
162 | log: (...args) =>
|
163 | q.push(() => this.send({ items: args, channel: logChannel || defaultChannel })),
|
164 | warn: (...args) =>
|
165 | q.push(() => this.send({ items: args, channel: warnChannel || defaultChannel })),
|
166 | error: (...args) =>
|
167 | q.push(() => this.send({ items: args, channel: errorChannel || defaultChannel })),
|
168 | },
|
169 | minLogLevel,
|
170 | )
|
171 | }
|
172 | }
|
173 |
|
174 | export function slackDefaultMessagePrefixHook(msg: SlackMessage): string[] {
|
175 | const tokens = [localTime().toPretty()]
|
176 | const { ctx } = msg
|
177 |
|
178 |
|
179 | if (GAE && ctx && typeof ctx === 'object' && typeof ctx.header === 'function') {
|
180 | tokens.push(ctx.header('x-appengine-country')!, ctx.header('x-appengine-city')!)
|
181 | }
|
182 |
|
183 | return tokens.filter(Boolean)
|
184 | }
|