UNPKG

4.83 kBPlain TextView Raw
1import {
2 _omit,
3 AnyObject,
4 CommonLogger,
5 commonLoggerMinLevel,
6 CommonLogLevel,
7 localTime,
8 PQueue,
9} from '@naturalcycles/js-lib'
10import got from 'got'
11import { inspectAny, InspectAnyOptions } from '..'
12import {
13 SlackApiBody,
14 SlackAttachmentField,
15 SlackMessage,
16 SlackServiceCfg,
17} from './slack.service.model'
18
19const GAE = !!process.env['GAE_INSTANCE']
20
21const DEFAULTS: SlackMessage = {
22 username: 'bot',
23 channel: '#log',
24 icon_emoji: ':spider_web:',
25 items: 'no text',
26}
27
28const INSPECT_OPT: InspectAnyOptions = {
29 colors: false,
30 includeErrorData: true,
31 includeErrorStack: true,
32}
33
34/**
35 * Has 2 main methods:
36 *
37 * 1. .send({ items: any, channel: ..., ... })
38 * Low-level method with all possible options available.
39 *
40 * 2. .log(...items: any[])
41 * Shortcut method to "just log a bunch of things", everything is "by default" there.
42 *
43 * .send method has a shortcut:
44 * .send(string, ctx?: CTX)
45 */
46export 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 * Allows to "log" many things at once, similar to `console.log(one, two, three).
63 */
64 async log(...items: any[]): Promise<void> {
65 await this.send({
66 // If it's an Array of just 1 item - treat it as non-array
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 // If String is passed as first argument - just transform it to a full SlackMessage
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 // Transform msg.kv into msg.attachments
86 if (msg.kv) {
87 ;(msg.attachments ||= []).push({ fields: this.kvToFields(msg.kv) })
88
89 delete msg.kv // to not pass it all the way to Slack Api
90 }
91
92 let text: string
93
94 // Array has a special treatment here
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 // Wrap in markdown-text-block if it's anything but plain String
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 // filtered out!
112
113 const json: SlackApiBody = _omit(
114 {
115 ...DEFAULTS,
116 ...this.cfg.defaults,
117 ...msg,
118 // Text with Prefix
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 // ignore (unless throwOnError is set)
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 * Returns a CommonLogger implementation based on this SlackService instance.
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
174export function slackDefaultMessagePrefixHook(msg: SlackMessage): string[] {
175 const tokens = [localTime().toPretty()]
176 const { ctx } = msg
177
178 // AppEngine-specific decoration
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}