UNPKG

4 kBPlain TextView Raw
1import { StringMap } from '@naturalcycles/js-lib'
2import { dayjs } from '@naturalcycles/time-lib'
3import got from 'got'
4import { Debug, DebugLogLevel, inspectAny, InspectAnyOptions } from '..'
5import {
6 SlackApiBody,
7 SlackAttachmentField,
8 SlackMessage,
9 SlackServiceCfg,
10} from './slack.service.model'
11
12const GAE = !!process.env['GAE_INSTANCE']
13
14const DEFAULTS = (): SlackMessage => ({
15 username: 'bot',
16 channel: '#log',
17 icon_emoji: ':spider_web:',
18 items: 'no text',
19})
20
21const inspectOpt: InspectAnyOptions = {
22 colors: false,
23}
24
25const log = Debug('nc:nodejs-lib:slack')
26
27/**
28 * Has 2 main methods:
29 *
30 * 1. .send({ items: any, channel: ..., ... })
31 * Low-level method with all possible options available.
32 *
33 * 2. .log(...items: any[])
34 * Shortcut method to "just log a bunch of things", everything is "by default" there.
35 *
36 * .send method has a shortcut:
37 * .send(string, ctx?: CTX)
38 */
39export class SlackService<CTX = any> {
40 constructor(cfg: Partial<SlackServiceCfg<CTX>>) {
41 this.cfg = {
42 messagePrefixHook: slackDefaultMessagePrefixHook,
43 ...cfg,
44 }
45 }
46
47 public cfg!: SlackServiceCfg<CTX>
48
49 /**
50 * Allows to "log" many things at once, similar to `console.log(one, two, three).
51 */
52 async log(...items: any[]): Promise<void> {
53 await this.send({
54 // If it's an Array of just 1 item - treat it as non-array
55 items: items.length === 1 ? items[0] : items,
56 })
57 }
58
59 async send(msg: SlackMessage<CTX> | string, ctx?: CTX): Promise<void> {
60 const { webhookUrl, messagePrefixHook } = this.cfg
61
62 // If String is passed as first argument - just transform it to a full SlackMessage
63 if (typeof msg === 'string') {
64 msg = {
65 items: msg,
66 }
67 }
68
69 if (ctx !== undefined) {
70 Object.assign(msg, { ctx })
71 }
72
73 if (!msg.noLog) {
74 log[msg.level || DebugLogLevel.info](
75 ...[msg.items, msg.kv, msg.attachments, msg.mentions].filter(Boolean),
76 )
77 }
78
79 if (!webhookUrl) return
80
81 // Transform msg.kv into msg.attachments
82 if (msg.kv) {
83 msg.attachments = [...(msg.attachments || []), { fields: this.kvToFields(msg.kv) }]
84
85 delete msg.kv // to not pass it all the way to Slack Api
86 }
87
88 let text: string
89
90 // Array has a special treatment here
91 if (Array.isArray(msg.items)) {
92 text = msg.items.map(t => inspectAny(t, inspectOpt)).join('\n')
93 } else {
94 text = inspectAny(msg.items, inspectOpt)
95 }
96
97 // Wrap in markdown-text-block if it's anything but plain String
98 if (typeof msg.items !== 'string') {
99 text = '```' + text + '```'
100 }
101
102 if (msg.mentions?.length) {
103 text += '\n' + msg.mentions.map(s => `<@${s}>`).join(' ')
104 }
105
106 const prefix = await messagePrefixHook(msg)
107 if (prefix === null) return // filtered out!
108
109 const json: SlackApiBody = {
110 ...DEFAULTS(),
111 ...this.cfg.defaults,
112 ...msg,
113 // Text with Prefix
114 text: [prefix.join(': '), text].filter(Boolean).join('\n'),
115 }
116
117 // they're not needed in the json payload
118 delete json['items']
119 delete json['ctx']
120 delete json['noLog']
121
122 json.channel = (this.cfg.channelByLevel || {})[msg.level!] || json.channel
123
124 await got
125 .post(webhookUrl, {
126 json,
127 responseType: 'text',
128 })
129 .catch(err => {
130 // ignore (unless throwOnError is set)
131 if ((msg as SlackMessage).throwOnError) throw err
132 })
133 }
134
135 kvToFields(kv: StringMap<any>): SlackAttachmentField[] {
136 return Object.entries(kv).map(([k, v]) => ({
137 title: k,
138 value: String(v),
139 short: String(v).length < 80,
140 }))
141 }
142}
143
144export function slackDefaultMessagePrefixHook(msg: SlackMessage): string[] {
145 const tokens = [dayjs().toPretty()]
146 const { ctx } = msg
147
148 // AppEngine-specific decoration
149 if (GAE && ctx && typeof ctx === 'object' && typeof ctx.header === 'function') {
150 tokens.push(ctx.header('x-appengine-country')!, ctx.header('x-appengine-city')!)
151 }
152
153 return tokens.filter(Boolean)
154}