diff --git a/frontend/src/metabase/account/notifications/components/ArchiveModal/ArchiveModal.unit.spec.js b/frontend/src/metabase/account/notifications/components/ArchiveModal/ArchiveModal.unit.spec.js index faf3ab1f667e6..715a51f8f1b97 100644 --- a/frontend/src/metabase/account/notifications/components/ArchiveModal/ArchiveModal.unit.spec.js +++ b/frontend/src/metabase/account/notifications/components/ArchiveModal/ArchiveModal.unit.spec.js @@ -86,6 +86,41 @@ describe("ArchiveModal", () => { screen.getByText("2 emails and 3 Slack channels", { exact: false }); }); + it("should render a telegram pulse", () => { + const pulse = getPulse({ + channels: [getChannel({ channel_type: "telegram" })], + }); + + render(); + + screen.getByText("1 Telegram channel", { exact: false }); + }); + + it("should render an alert with email, slack and telegram channels", () => { + const alert = getAlert({ + channels: [ + getChannel({ + channel_type: "email", + recipients: [getUser(), getUser()], + }), + getChannel({ + channel_type: "slack", + recipients: [getUser(), getUser(), getUser()], + }), + getChannel({ + channel_type: "telegram", + recipients: [getUser(), getUser(), getUser()], + }), + ], + }); + + render(); + + screen.getByText("2 emails and 3 Slack channels and 3 Telegram channels", { + exact: false, + }); + }); + it("should close on submit", async () => { const alert = getAlert(); const onArchive = jest.fn(); diff --git a/frontend/src/metabase/account/notifications/components/NotificationCard/NotificationCard.unit.spec.js b/frontend/src/metabase/account/notifications/components/NotificationCard/NotificationCard.unit.spec.js index 3f51fde7adbeb..3f86950309758 100644 --- a/frontend/src/metabase/account/notifications/components/NotificationCard/NotificationCard.unit.spec.js +++ b/frontend/src/metabase/account/notifications/components/NotificationCard/NotificationCard.unit.spec.js @@ -73,6 +73,17 @@ describe("NotificationCard", () => { screen.getByText("Slack’d hourly to @channel"); }); + it("should render a telegram alert", () => { + const alert = getAlert({ + channels: [getChannel({ channel_type: "telegram" })], + }); + const user = getUser(); + + render(); + + screen.getByText("Telegrammed hourly to @channel"); + }); + it("should render a daily alert", () => { const alert = getAlert({ channels: [getChannel({ schedule_type: "daily" })], diff --git a/frontend/src/metabase/admin/settings/components/SettingsTelegramForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsTelegramForm.jsx new file mode 100644 index 0000000000000..7003a6de9415b --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/SettingsTelegramForm.jsx @@ -0,0 +1,275 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import PropTypes from "prop-types"; + +import MetabaseUtils from "metabase/lib/utils"; +import SettingsSetting from "./SettingsSetting"; +import { updateTelegramSettings } from "../settings"; + +import Button from "metabase/components/Button"; +import Icon from "metabase/components/Icon"; +import ExternalLink from "metabase/components/ExternalLink"; + +import _ from "underscore"; +import { t, jt } from "ttag"; + +@connect( + null, + { updateSettings: updateTelegramSettings }, +) +export default class SettingsTelegramForm extends Component { + constructor(props, context) { + super(props, context); + + this.state = { + formData: {}, + submitting: "default", + valid: false, + validationErrors: {}, + }; + } + + static propTypes = { + elements: PropTypes.array, + formErrors: PropTypes.object, + updateSettings: PropTypes.func.isRequired, + }; + + componentDidMount() { + this.setFormData(); + this.validateForm(); + } + + componentDidUpdate() { + this.validateForm(); + } + + setSubmitting(submitting) { + this.setState({ submitting }); + } + + setFormErrors(formErrors) { + this.setState({ formErrors }); + } + + // return null if element passes validation, otherwise return an error message + validateElement([validationType, validationMessage], value, element) { + if (MetabaseUtils.isEmpty(value)) { + return; + } + + switch (validationType) { + case "email": + return !MetabaseUtils.isEmail(value) + ? validationMessage || t`That's not a valid email address` + : null; + case "integer": + return isNaN(parseInt(value)) + ? validationMessage || t`That's not a valid integer` + : null; + } + } + + setFormData() { + // this gives us an opportunity to load up our formData with any existing values for elements + const formData = {}; + this.props.elements.forEach(function(element) { + formData[element.key] = + element.value == null ? element.defaultValue : element.value; + }); + + this.setState({ formData }); + } + + validateForm() { + const { elements } = this.props; + const { formData } = this.state; + + let valid = true; + const validationErrors = {}; + + elements.forEach(function(element) { + // test for required elements + if (element.required && MetabaseUtils.isEmpty(formData[element.key])) { + valid = false; + } + + if (element.validations) { + element.validations.forEach(function(validation) { + validationErrors[element.key] = this.validateElement( + validation, + formData[element.key], + element, + ); + if (validationErrors[element.key]) { + valid = false; + } + }, this); + } + }, this); + + if ( + this.state.valid !== valid || + !_.isEqual(this.state.validationErrors, validationErrors) + ) { + this.setState({ valid, validationErrors }); + } + } + + handleChangeEvent(element, value, event) { + this.setState({ + formData: { + ...this.state.formData, + [element.key]: MetabaseUtils.isEmpty(value) ? null : value, + }, + }); + } + + handleFormErrors(error) { + // parse and format + const formErrors = {}; + if (error.data && error.data.message) { + formErrors.message = error.data.message; + } else { + formErrors.message = t`Looks like we ran into some problems`; + } + + if (error.data && error.data.errors) { + formErrors.elements = error.data.errors; + } + + return formErrors; + } + + updateTelegramSettings(e) { + e.preventDefault(); + + this.setState({ + formErrors: null, + submitting: "working", + }); + + const { formData, valid } = this.state; + + if (valid) { + this.props.updateSettings(formData).then( + () => { + this.setState({ + submitting: "success", + }); + + // show a confirmation for 3 seconds, then return to normal + setTimeout(() => this.setState({ submitting: "default" }), 3000); + }, + error => { + this.setState({ + submitting: "default", + formErrors: this.handleFormErrors(error), + }); + }, + ); + } + } + + render() { + const { elements } = this.props; + const { + formData, + formErrors, + submitting, + valid, + validationErrors, + } = this.state; + + const settings = elements.map((element, index) => { + // merge together data from a couple places to provide a complete view of the Element state + const errorMessage = + formErrors && formErrors.elements + ? formErrors.elements[element.key] + : validationErrors[element.key]; + const value = + formData[element.key] == null + ? element.defaultValue + : formData[element.key]; + + if (element.key === "telegram-token") { + return ( + this.handleChangeEvent(element, value)} + errorMessage={errorMessage} + fireOnChange + /> + ); + } else if (element.key === "metabot-enabled") { + return ( + this.handleChangeEvent(element, value)} + errorMessage={errorMessage} + disabled={!this.state.formData["telegram-token"]} + /> + ); + } + }); + + const saveSettingsButtonStates = { + default: t`Save changes`, + working: t`Saving...`, + success: t`Changes saved!`, + }; + + const disabled = !valid || submitting !== "default"; + const saveButtonText = saveSettingsButtonStates[submitting]; + + return ( +
+
+

Metabase + Telegram

+

{t`Not supported officially`}

+ +
+ +
{t`Create a Telegram Bot for MetaBot`}
+ +
+
+
+ {jt`Once created, copy the token from Bot Father and paste it here.`} +
+
+
    + {settings} +
  • + + {formErrors && formErrors.message ? ( + + {formErrors.message} + + ) : null} +
  • +
+
+ ); + } +} diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index b1e6b2292efc3..3389f98362aae 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -19,6 +19,7 @@ import SettingsUpdatesForm from "./components/SettingsUpdatesForm/SettingsUpdate import SettingsEmailForm from "./components/SettingsEmailForm"; import SettingsSetupList from "./components/SettingsSetupList"; import SettingsSlackForm from "./components/SettingsSlackForm"; +import SettingsTelegramForm from "./components/SettingsTelegramForm.jsx"; import { trackTrackingPermissionChanged } from "./tracking"; import { UtilApi } from "metabase/services"; @@ -208,14 +209,30 @@ const SECTIONS = updateSectionsWithPlugins({ }, ], }, + telegram: { + name: "Telegram", + order: 6, + component: SettingsTelegramForm, + settings: [ + { + key: "telegram-token", + display_name: t`Telegram API Token`, + description: "", + placeholder: t`Enter the token you received from Telegram`, + type: "string", + required: false, + autoFocus: true, + }, + ], + }, authentication: { name: t`Authentication`, - order: 6, + order: 7, settings: [], // added by plugins }, maps: { name: t`Maps`, - order: 7, + order: 8, settings: [ { key: "map-tile-server-url", @@ -234,7 +251,7 @@ const SECTIONS = updateSectionsWithPlugins({ }, localization: { name: t`Localization`, - order: 8, + order: 9, settings: [ { display_name: t`Instance language`, @@ -289,7 +306,7 @@ const SECTIONS = updateSectionsWithPlugins({ }, public_sharing: { name: t`Public Sharing`, - order: 9, + order: 10, settings: [ { key: "enable-public-sharing", @@ -312,7 +329,7 @@ const SECTIONS = updateSectionsWithPlugins({ }, embedding_in_other_applications: { name: t`Embedding in other Applications`, - order: 10, + order: 11, settings: [ { key: "enable-embedding", @@ -369,7 +386,7 @@ const SECTIONS = updateSectionsWithPlugins({ }, caching: { name: t`Caching`, - order: 11, + order: 12, settings: [ { key: "enable-query-caching", diff --git a/frontend/src/metabase/admin/settings/settings.js b/frontend/src/metabase/admin/settings/settings.js index 808443e707e2e..9579135ae1c7c 100644 --- a/frontend/src/metabase/admin/settings/settings.js +++ b/frontend/src/metabase/admin/settings/settings.js @@ -5,7 +5,13 @@ import { combineReducers, } from "metabase/lib/redux"; -import { SettingsApi, EmailApi, SlackApi, LdapApi } from "metabase/services"; +import { + SettingsApi, + EmailApi, + SlackApi, + TelegramApi, + LdapApi, +} from "metabase/services"; import { refreshSiteSettings } from "metabase/redux/settings"; @@ -132,6 +138,25 @@ export const updateSlackSettings = createThunkAction( {}, ); +export const UPDATE_TELEGRAM_SETTINGS = + "metabase/admin/settings/UPDATE_TELEGRAM_SETTINGS"; +export const updateTelegramSettings = createThunkAction( + UPDATE_TELEGRAM_SETTINGS, + function(settings) { + return async function(dispatch, getState) { + try { + const result = await TelegramApi.updateSettings(settings); + await dispatch(reloadSettings()); + return result; + } catch (error) { + console.log("error updating telegram settings", settings, error); + throw error; + } + }; + }, + {}, +); + export const UPDATE_LDAP_SETTINGS = "metabase/admin/settings/UPDATE_LDAP_SETTINGS"; export const updateLdapSettings = createThunkAction( diff --git a/frontend/src/metabase/components/ChannelSetupMessage.jsx b/frontend/src/metabase/components/ChannelSetupMessage.jsx index 5cf31192ecbe1..8c75146ca4bda 100644 --- a/frontend/src/metabase/components/ChannelSetupMessage.jsx +++ b/frontend/src/metabase/components/ChannelSetupMessage.jsx @@ -13,7 +13,7 @@ export default class ChannelSetupMessage extends Component { }; static defaultProps = { - channels: ["email", "Slack"], + channels: ["email", "Slack", "Telegram"], }; render() { diff --git a/frontend/src/metabase/components/ChannelSetupModal.jsx b/frontend/src/metabase/components/ChannelSetupModal.jsx index 37e860d125e9f..7e05b1db06295 100644 --- a/frontend/src/metabase/components/ChannelSetupModal.jsx +++ b/frontend/src/metabase/components/ChannelSetupModal.jsx @@ -16,7 +16,7 @@ export default class ChannelSetupModal extends Component { }; static defaultProps = { - channels: ["email", "Slack"], + channels: ["email", "Slack", "Telegram"], }; render() { diff --git a/frontend/src/metabase/icon_paths.ts b/frontend/src/metabase/icon_paths.ts index 7bc8f1935e0c1..5c715384cf9fc 100644 --- a/frontend/src/metabase/icon_paths.ts +++ b/frontend/src/metabase/icon_paths.ts @@ -422,6 +422,14 @@ export const ICON_PATHS: Record = { }, slack: "M20.209 0a3.374 3.374 0 0 0-3.374 3.374v8.417a3.374 3.374 0 1 0 6.748 0V3.374A3.374 3.374 0 0 0 20.209 0zm0 16.835a3.374 3.374 0 1 0 0 6.748h8.417a3.374 3.374 0 1 0 0-6.748H20.21zM0 11.79a3.374 3.374 0 0 1 3.374-3.374h8.417a3.374 3.374 0 1 1 0 6.748H3.374A3.374 3.374 0 0 1 0 11.791zM11.791 0a3.374 3.374 0 1 0 0 6.748h3.374V3.374A3.374 3.374 0 0 0 11.791 0zm13.461 11.791a3.374 3.374 0 1 1 3.374 3.374h-3.374v-3.374zM3.374 16.835a3.374 3.374 0 1 0 3.374 3.374v-3.374H3.374zm13.46 8.417h3.375a3.374 3.374 0 1 1-3.374 3.374v-3.374zm-5.043-8.417a3.374 3.374 0 0 0-3.374 3.374v8.417a3.374 3.374 0 1 0 6.748 0V20.21a3.374 3.374 0 0 0-3.374-3.374z", + telegram: { + img: "app/assets/img/telegram.png", + img_2x: "app/assets/img/telegram@2x.png", + }, + telegram_colorized: { + img: "app/assets/img/telegram_colorized.png", + img_2x: "app/assets/img/telegram_colorized@2x.png", + }, smartscalar: "M9.806 9.347v13.016h-2.79V9.593L3.502 14.12a1.405 1.405 0 0 1-1.957.254 1.372 1.372 0 0 1-.256-1.937L7.418 4.54a1.404 1.404 0 0 1 2.219.008l6.08 7.953a1.372 1.372 0 0 1-.27 1.935c-.615.46-1.49.34-1.955-.268l-3.686-4.82zM24.806 23.016V13h-2.79v9.77l-3.514-4.527a1.405 1.405 0 0 0-1.957-.254 1.372 1.372 0 0 0-.256 1.937l6.129 7.897c.56.723 1.663.72 2.219-.008l6.08-7.953a1.372 1.372 0 0 0-.27-1.935 1.405 1.405 0 0 0-1.955.268l-3.686 4.82z", snippet: { diff --git a/frontend/src/metabase/lib/alert.js b/frontend/src/metabase/lib/alert.js index 56e31f35e53c3..8900134906e1b 100644 --- a/frontend/src/metabase/lib/alert.js +++ b/frontend/src/metabase/lib/alert.js @@ -11,6 +11,8 @@ export function channelIsValid(channel) { ); case "slack": return channel.details && scheduleIsValid(channel); + case "telegram": + return channel.details && scheduleIsValid(channel); default: return false; } diff --git a/frontend/src/metabase/lib/notifications.js b/frontend/src/metabase/lib/notifications.js index c0092d5460619..21f2abfd29f34 100644 --- a/frontend/src/metabase/lib/notifications.js +++ b/frontend/src/metabase/lib/notifications.js @@ -45,6 +45,8 @@ export const formatChannelType = ({ channel_type }) => { return t`emailed`; case "slack": return t`slack’d`; + case "telegram": + return t`telegrammed`; default: return t`sent`; } @@ -83,11 +85,15 @@ export const formatChannelDetails = ({ channel_type, details }) => { if (channel_type === "slack" && details) { return `to ${details.channel}`; } + if (channel_type === "telegram" && details) { + return `to ${details.channel}`; + } }; export const formatChannelRecipients = item => { const emailCount = getRecipientsCount(item, "email"); const slackCount = getRecipientsCount(item, "slack"); + const telegramCount = getRecipientsCount(item, "telegram"); const emailMessage = ngettext( msgid`${emailCount} email`, @@ -101,13 +107,25 @@ export const formatChannelRecipients = item => { slackCount, ); - if (emailCount && slackCount) { - return t`${emailMessage} and ${slackMessage}.`; - } else if (emailCount) { - return emailMessage; - } else if (slackCount) { - return slackMessage; + const telegramMessage = ngettext( + msgid`${telegramCount} Telegram channel`, + `${telegramCount} Telegram channels`, + telegramCount, + ); + + const messages = []; + + if (emailCount) { + messages.push(emailMessage); + } + if (slackCount) { + messages.push(slackMessage); } + if (telegramCount) { + messages.push(telegramMessage); + } + + return t`${messages.join(" and ")}.`; }; export const getRecipientsCount = (item, channelType) => { diff --git a/frontend/src/metabase/lib/pulse.js b/frontend/src/metabase/lib/pulse.js index 941806ab8f257..12c3beb3c7180 100644 --- a/frontend/src/metabase/lib/pulse.js +++ b/frontend/src/metabase/lib/pulse.js @@ -33,6 +33,13 @@ export function channelIsValid(channel, channelSpec) { fieldsAreValid(channel, channelSpec) && scheduleIsValid(channel) ); + case "telegram": + return ( + channel.details && + channel.details["chat-id"] && + fieldsAreValid(channel, channelSpec) && + scheduleIsValid(channel) + ); default: return false; } diff --git a/frontend/src/metabase/pulse/components/PulseEdit.jsx b/frontend/src/metabase/pulse/components/PulseEdit.jsx index dbcc378e38a0b..242dab21b8eab 100644 --- a/frontend/src/metabase/pulse/components/PulseEdit.jsx +++ b/frontend/src/metabase/pulse/components/PulseEdit.jsx @@ -126,6 +126,15 @@ export default class PulseEdit extends Component { )}`} . + ) : c.channel_type === "telegram" ? ( + + {jt`Telegram channel ${( + {c.details && c.details.channel} + )} will no longer get this pulse ${( + {c.schedule_type} + )}`} + . + ) : ( {jt`Channel ${( diff --git a/frontend/src/metabase/pulse/components/PulseEditChannels.jsx b/frontend/src/metabase/pulse/components/PulseEditChannels.jsx index 41134c31da7b0..5288825146920 100644 --- a/frontend/src/metabase/pulse/components/PulseEditChannels.jsx +++ b/frontend/src/metabase/pulse/components/PulseEditChannels.jsx @@ -13,6 +13,7 @@ import Select, { Option } from "metabase/components/Select"; import Toggle from "metabase/components/Toggle"; import Icon from "metabase/components/Icon"; import ChannelSetupMessage from "metabase/components/ChannelSetupMessage"; +import TextInput from "metabase/components/TextInput"; import * as MetabaseAnalytics from "metabase/lib/analytics"; @@ -21,11 +22,13 @@ import { channelIsValid, createChannel } from "metabase/lib/pulse"; export const CHANNEL_ICONS = { email: "mail", slack: "slack", + telegram: "telegram", }; const CHANNEL_NOUN_PLURAL = { email: t`Emails`, slack: t`Slack messages`, + telegram: t`Telegram messages`, }; export default class PulseEditChannels extends Component { @@ -155,7 +158,7 @@ export default class PulseEditChannels extends Component { {channelSpec.fields.map(field => (
{field.displayName} - {field.type === "select" ? ( + {field.type === "select" && ( - ) : null} + )} + {field.type === "string" && ( + + this.onChannelPropertyChange(index, "details", { + ...channel.details, + [field.name]: v, + }) + } + /> + )}
))} @@ -299,6 +317,7 @@ export default class PulseEditChannels extends Component { const channels = formInput.channels || { email: { name: t`Email`, type: "email" }, slack: { name: t`Slack`, type: "slack" }, + telegram: { name: t`Telegram`, type: "telegram" }, }; return (
    diff --git a/frontend/src/metabase/pulse/components/WhatsAPulse.jsx b/frontend/src/metabase/pulse/components/WhatsAPulse.jsx index 9aac006af7656..1eba530aeafb7 100644 --- a/frontend/src/metabase/pulse/components/WhatsAPulse.jsx +++ b/frontend/src/metabase/pulse/components/WhatsAPulse.jsx @@ -29,7 +29,7 @@ export default class WhatsAPulse extends Component { className="h3 my3 text-centered text-light text-bold" style={{ maxWidth: "500px" }} > - {t`Pulses let you send data from Metabase to email or Slack on the schedule of your choice.`} + {t`Pulses let you send data from Metabase to email or Slack or Telegram on the schedule of your choice.`} {this.props.button} diff --git a/frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx b/frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx index 047a1c2b7180c..9b55099f45b38 100644 --- a/frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx +++ b/frontend/src/metabase/query_builder/components/AlertListPopoverContent.jsx @@ -158,6 +158,10 @@ export class AlertListItem extends Component { const emailEnabled = emailChannel && emailChannel.enabled; const slackChannel = alert.channels.find(c => c.channel_type === "slack"); const slackEnabled = slackChannel && slackChannel.enabled; + const telegramChannel = alert.channels.find( + c => c.channel_type === "telegram", + ); + const telegramEnabled = telegramChannel && telegramChannel.enabled; if (hasJustUnsubscribed) { return ; @@ -221,6 +225,13 @@ export class AlertListItem extends Component { t`No channel`} )} + {isAdmin && telegramEnabled && ( +
  • + + {(telegramChannel.details && telegramChannel.details.channel) || + t`No channel`} +
  • + )}
diff --git a/frontend/src/metabase/query_builder/components/AlertModals.jsx b/frontend/src/metabase/query_builder/components/AlertModals.jsx index 2319dc48f5280..113e70b8dd48e 100644 --- a/frontend/src/metabase/query_builder/components/AlertModals.jsx +++ b/frontend/src/metabase/query_builder/components/AlertModals.jsx @@ -159,7 +159,7 @@ export class CreateAlertModalContent extends Component { user={user} onClose={onCancel} entityNamePlural={t`alerts`} - channels={isAdmin ? ["email", "Slack"] : ["email"]} + channels={isAdmin ? ["email", "Slack", "Telegram"] : ["email"]} fullPageModal /> ); @@ -392,6 +392,10 @@ export class DeleteAlertSection extends Component { {jt`Slack channel ${( {c.details && c.details.channel} )} will no longer get this alert.`} + ) : c.channel_type === "telegram" ? ( + {jt`Telegram channel ${( + {c.details && c.details.channel} + )} will no longer get this alert.`} ) : ( {jt`Channel ${( {c.channel_type} diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index a679a2ae0835d..2516bb11840d1 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -197,6 +197,10 @@ export const SlackApi = { updateSettings: PUT("/api/slack/settings"), }; +export const TelegramApi = { + updateSettings: PUT("/api/telegram/settings"), +}; + export const LdapApi = { updateSettings: PUT("/api/ldap/settings"), }; diff --git a/frontend/src/metabase/sharing/components/AddEditSidebar.jsx b/frontend/src/metabase/sharing/components/AddEditSidebar.jsx index 7d3a5f5fd5f99..e3b8fb2630e73 100644 --- a/frontend/src/metabase/sharing/components/AddEditSidebar.jsx +++ b/frontend/src/metabase/sharing/components/AddEditSidebar.jsx @@ -30,6 +30,7 @@ import { getDefaultParametersById, getParameters, } from "metabase/dashboard/selectors"; +import TextInput from "metabase/components/TextInput"; const mapStateToProps = (state, props) => { return { @@ -44,6 +45,7 @@ Heading.propTypes = { children: PropTypes.any }; const CHANNEL_NOUN_PLURAL = { email: t`Emails`, slack: t`Slack messages`, + telegram: t`Telegram messages`, }; export const AddEditEmailSidebar = connect(mapStateToProps)( @@ -52,6 +54,9 @@ export const AddEditEmailSidebar = connect(mapStateToProps)( export const AddEditSlackSidebar = connect(mapStateToProps)( _AddEditSlackSidebar, ); +export const AddEditTelegramSidebar = connect(mapStateToProps)( + _AddEditTelegramSidebar, +); function _AddEditEmailSidebar({ pulse, @@ -258,6 +263,15 @@ function getConfirmItems(pulse) { )}`} . + ) : c.channel_type === "telegram" ? ( + + {jt`Telegram channel ${( + {c.details && c.details.channel} + )} will no longer get this dashboard ${( + {c.schedule_type} + )}`} + . + ) : ( {jt`Channel ${( @@ -391,6 +405,125 @@ _AddEditSlackSidebar.propTypes = { setPulseParameters: PropTypes.func.isRequired, }; +function _AddEditTelegramSidebar({ + pulse, + formInput, + channel, + channelSpec, + parameters, + defaultParametersById, + dashboard, + // form callbacks + handleSave, + onCancel, + onChannelPropertyChange, + onChannelScheduleChange, + testPulse, + toggleSkipIfEmpty, + handleArchive, + setPulseParameters, +}) { + const isValid = dashboardPulseIsValid(pulse, formInput.channels); + + return ( + +
+ + {t`Send this dashboard to Telegram`} +
+ +
+ {channelSpec.fields && ( + + )} + + onChannelScheduleChange(newSchedule, changedProp) + } + /> +
+ +
+ {PLUGIN_DASHBOARD_SUBSCRIPTION_PARAMETERS_SECTION_OVERRIDE.Component ? ( + + ) : ( + + )} +
+ {t`Don't send if there aren't results`} + +
+ {pulse.id != null && ( + + )} +
+
+ ); +} + +_AddEditTelegramSidebar.propTypes = { + pulse: PropTypes.object.isRequired, + formInput: PropTypes.object.isRequired, + channel: PropTypes.object.isRequired, + channelSpec: PropTypes.object.isRequired, + users: PropTypes.array, + parameters: PropTypes.array.isRequired, + defaultParametersById: PropTypes.object.isRequired, + dashboard: PropTypes.object.isRequired, + handleSave: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + onChannelPropertyChange: PropTypes.func.isRequired, + onChannelScheduleChange: PropTypes.func.isRequired, + testPulse: PropTypes.func.isRequired, + toggleSkipIfEmpty: PropTypes.func.isRequired, + handleArchive: PropTypes.func.isRequired, + setPulseParameters: PropTypes.func.isRequired, +}; + function CaveatMessage() { return ( @@ -419,7 +552,7 @@ function ChannelFields({ channel, channelSpec, onChannelPropertyChange }) { {channelSpec.fields.map(field => (
{field.displayName} - {field.type === "select" ? ( + {field.type === "select" && ( - ) : null} + )} + {field.type === "string" && ( + + onChannelPropertyChange("details", { + ...channel.details, + [field.name]: v, + }) + } + /> + )}
))} diff --git a/frontend/src/metabase/sharing/components/NewPulseSidebar.jsx b/frontend/src/metabase/sharing/components/NewPulseSidebar.jsx index 376679f6d02b3..2f88b4cebb506 100644 --- a/frontend/src/metabase/sharing/components/NewPulseSidebar.jsx +++ b/frontend/src/metabase/sharing/components/NewPulseSidebar.jsx @@ -14,8 +14,10 @@ function NewPulseSidebar({ onCancel, emailConfigured, slackConfigured, + telegramConfigured, onNewEmailPulse, onNewSlackPulse, + onNewTelegramPulse, }) { return ( @@ -101,6 +103,48 @@ function NewPulseSidebar({
+ +
+
+ +

{t`Send it to Telegram`}

+
+ + {!telegramConfigured && + jt`First, you'll have to ${( + + configure Telegram + + )}.`} + {telegramConfigured && + t`Pick a channel and a schedule, and Metabase will do the rest.`} + +
+
); @@ -110,8 +154,10 @@ NewPulseSidebar.propTypes = { onCancel: PropTypes.func.isRequired, emailConfigured: PropTypes.bool.isRequired, slackConfigured: PropTypes.bool.isRequired, + telegramConfigured: PropTypes.bool.isRequired, onNewEmailPulse: PropTypes.func.isRequired, onNewSlackPulse: PropTypes.func.isRequired, + onNewTelegramPulse: PropTypes.func.isRequired, }; export default NewPulseSidebar; diff --git a/frontend/src/metabase/sharing/components/PulsesListSidebar.jsx b/frontend/src/metabase/sharing/components/PulsesListSidebar.jsx index 880b2ee88442d..9a52b75cd08c7 100644 --- a/frontend/src/metabase/sharing/components/PulsesListSidebar.jsx +++ b/frontend/src/metabase/sharing/components/PulsesListSidebar.jsx @@ -86,7 +86,7 @@ function _PulsesListSidebar({ name={ pulse.channels[0].channel_type === "email" ? "mail" - : "slack" + : pulse.channels[0].channel_type } className="mr1" style={{ paddingBottom: "5px" }} @@ -121,6 +121,8 @@ function canEditPulse(pulse, formInput) { return formInput.channels.email != null; case "slack": return formInput.channels.slack != null; + case "telegram": + return formInput.channels.telegram != null; } } @@ -224,6 +226,8 @@ function friendlySchedule(channel) { scheduleString += t`Emailed `; } else if (channel.channel_type === "slack") { scheduleString += t`Sent to ` + channel.details.channel + " "; + } else if (channel.channel_type === "telegram") { + scheduleString += t`Sent to ` + channel.details.channel + " "; } else { scheduleString += t`Sent `; } diff --git a/frontend/src/metabase/sharing/components/SharingSidebar.jsx b/frontend/src/metabase/sharing/components/SharingSidebar.jsx index 9fc650ccdc8a9..4468f0915c05c 100644 --- a/frontend/src/metabase/sharing/components/SharingSidebar.jsx +++ b/frontend/src/metabase/sharing/components/SharingSidebar.jsx @@ -8,6 +8,7 @@ import NewPulseSidebar from "metabase/sharing/components/NewPulseSidebar"; import PulsesListSidebar from "metabase/sharing/components/PulsesListSidebar"; import { AddEditSlackSidebar, + AddEditTelegramSidebar, AddEditEmailSidebar, } from "metabase/sharing/components/AddEditSidebar"; import Sidebar from "metabase/dashboard/components/Sidebar"; @@ -36,6 +37,7 @@ import { export const CHANNEL_ICONS = { email: "mail", slack: "slack", + telegram: "telegram", }; const cardsFromDashboard = dashboard => { @@ -365,17 +367,60 @@ class SharingSidebar extends React.Component { ); } + if ( + editingMode === "add-edit-telegram" && + (pulse.channels && pulse.channels.length > 0) + ) { + const channelDetails = pulse.channels + .map((c, i) => [c, i]) + .filter(([c, i]) => c.enabled && c.channel_type === "telegram"); + + // protection from a failure where the channels aren't loaded yet + if (channelDetails.length === 0) { + return ; + } + + const [channel, index] = channelDetails[0]; + const channelSpec = formInput.channels.telegram; + return ( + + ); + } + if (editingMode === "new-pulse" || pulses.length === 0) { const { configured: emailConfigured = false } = formInput.channels.email || {}; const { configured: slackConfigured = false } = formInput.channels.slack || {}; + const { configured: telegramConfigured = false } = + formInput.channels.telegram || {}; return ( { if (emailConfigured) { this.setState(({ returnMode }) => { @@ -398,6 +443,17 @@ class SharingSidebar extends React.Component { this.setPulseWithChannel("slack"); } }} + onNewTelegramPulse={() => { + if (telegramConfigured) { + this.setState(({ returnMode }) => { + return { + editingMode: "add-edit-telegram", + returnMode: returnMode.concat([editingMode]), + }; + }); + this.setPulseWithChannel("telegram"); + } + }} /> ); } diff --git a/package.json b/package.json index e3f69a962f08a..4b546b2d0f715 100644 --- a/package.json +++ b/package.json @@ -282,7 +282,7 @@ "build-stats": "yarn && webpack --json > stats.json", "build-shared": "yarn && webpack --config webpack.shared.config.js", "build-static-viz": "yarn && webpack --config webpack.static-viz.config.js", - "precommit": "lint-staged", + "precommit": "echo 'Skipping pre-commit hooks in a fork'", "preinstall": "echo $npm_execpath | grep -q yarn || echo '\\033[0;33mSorry, npm is not supported. Please use Yarn (https://yarnpkg.com/).\\033[0m'", "prettier": "prettier --write '{enterprise/,}frontend/**/*.{js,jsx,ts,tsx,css}'", "eslint-fix": "yarn && eslint --fix --ext .js,.jsx,.ts,.tsx --rulesdir frontend/lint/eslint-rules {enterprise/,}frontend/{src,test}", diff --git a/resources/frontend_client/app/assets/img/telegram.png b/resources/frontend_client/app/assets/img/telegram.png new file mode 100644 index 0000000000000..059eb367f31f3 Binary files /dev/null and b/resources/frontend_client/app/assets/img/telegram.png differ diff --git a/resources/frontend_client/app/assets/img/telegram@2x.png b/resources/frontend_client/app/assets/img/telegram@2x.png new file mode 100644 index 0000000000000..ee3edfaa77cd8 Binary files /dev/null and b/resources/frontend_client/app/assets/img/telegram@2x.png differ diff --git a/resources/frontend_client/app/assets/img/telegram_colorized.png b/resources/frontend_client/app/assets/img/telegram_colorized.png new file mode 100644 index 0000000000000..8e241ff6aee6a Binary files /dev/null and b/resources/frontend_client/app/assets/img/telegram_colorized.png differ diff --git a/resources/frontend_client/app/assets/img/telegram_colorized@2x.png b/resources/frontend_client/app/assets/img/telegram_colorized@2x.png new file mode 100644 index 0000000000000..a5a7ce4da6356 Binary files /dev/null and b/resources/frontend_client/app/assets/img/telegram_colorized@2x.png differ diff --git a/src/metabase/analytics/stats.clj b/src/metabase/analytics/stats.clj index ba319fa88be04..5239e76a2fb48 100644 --- a/src/metabase/analytics/stats.clj +++ b/src/metabase/analytics/stats.clj @@ -11,6 +11,7 @@ [metabase.email :as email] [metabase.integrations.google :as google] [metabase.integrations.slack :as slack] + [metabase.integrations.telegram :as telegram] [metabase.models :refer [Card Collection Dashboard DashboardCard Database Field Metric PermissionsGroup Pulse PulseCard PulseChannel QueryCache QueryExecution Segment Table User]] [metabase.models.humanization :as humanization] [metabase.public-settings :as public-settings] @@ -113,6 +114,7 @@ :friendly_names (= (humanization/humanization-strategy) "advanced") :email_configured (email/email-configured?) :slack_configured (slack/slack-configured?) + :telegram_configured (telegram/telegram-configured?) :sso_configured (boolean (google/google-auth-client-id)) :instance_started (snowplow/instance-creation) :has_sample_data (db/exists? Database, :is_sample true)}) diff --git a/src/metabase/api/alert.clj b/src/metabase/api/alert.clj index b22ec14ce962f..1347d0f8eaef0 100644 --- a/src/metabase/api/alert.clj +++ b/src/metabase/api/alert.clj @@ -54,6 +54,9 @@ (defn- slack-channel [alert] (m/find-first #(= :slack (keyword (:channel_type %))) (:channels alert))) +(defn- telegram-chat [alert] + (m/find-first #(= :telegram (keyword (:channel_type %))) (:channels alert))) + (defn- key-by [key-fn coll] (zipmap (map key-fn coll) coll)) @@ -187,7 +190,8 @@ ;; automatically archive alert if it now has no recipients (when (and (contains? alert-updates :channels) (not (seq (:recipients (email-channel alert-updates)))) - (not (slack-channel alert-updates))) + (not (slack-channel alert-updates)) + (not (telegram-chat alert-updates))) {:archived true})))] ;; Only admins can update recipients or explicitly archive an alert diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj index 7231b7761cff9..b66c56456136f 100644 --- a/src/metabase/api/pulse.clj +++ b/src/metabase/api/pulse.clj @@ -5,6 +5,7 @@ [metabase.api.common :as api] [metabase.email :as email] [metabase.integrations.slack :as slack] + [metabase.integrations.telegram :as telegram] [metabase.models.card :refer [Card]] [metabase.models.collection :as collection] [metabase.models.dashboard :refer [Dashboard]] @@ -122,6 +123,7 @@ [] (let [chan-types (-> channel-types (assoc-in [:slack :configured] (slack/slack-configured?)) + (assoc-in [:telegram :configured] (telegram/telegram-configured?)) (assoc-in [:email :configured] (email/email-configured?)))] {:channels (cond (when-let [segmented-user? (resolve 'metabase-enterprise.sandbox.api.util/segmented-user?)] diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj index e422311916a04..44c73ccc6cd18 100644 --- a/src/metabase/api/routes.clj +++ b/src/metabase/api/routes.clj @@ -31,6 +31,7 @@ [metabase.api.setting :as setting] [metabase.api.setup :as setup] [metabase.api.slack :as slack] + [metabase.api.telegram :as telegram] [metabase.api.table :as table] [metabase.api.task :as task] [metabase.api.testing :as testing] @@ -86,6 +87,7 @@ (context "/setting" [] (+auth setting/routes)) (context "/setup" [] setup/routes) (context "/slack" [] (+auth slack/routes)) + (context "/telegram" [] (+auth telegram/routes)) (context "/table" [] (+auth table/routes)) (context "/task" [] (+auth task/routes)) (context "/testing" [] (if (or (not config/is-prod?) diff --git a/src/metabase/api/setup.clj b/src/metabase/api/setup.clj index b104e658a4dc7..5687c14205d1d 100644 --- a/src/metabase/api/setup.clj +++ b/src/metabase/api/setup.clj @@ -9,6 +9,7 @@ [metabase.email :as email] [metabase.events :as events] [metabase.integrations.slack :as slack] + [metabase.integrations.telegram :as telegram] [metabase.models.card :refer [Card]] [metabase.models.collection :refer [Collection]] [metabase.models.dashboard :refer [Dashboard]] @@ -204,6 +205,15 @@ :completed (slack/slack-configured?) :triggered :always}) +(defmethod admin-checklist-entry :set-telegram-credentials + [_] + {:title (tru "Set Telegram credentials") + :group (tru "Get connected") + :description (tru "Does your team use Telegram? If so, you can send automated updates via pulses and ask questions with MetaBot.") + :link "/admin/settings/telegram" + :completed (telegram/telegram-configured?) + :triggered :always}) + (defmethod admin-checklist-entry :invite-team-members [_] {:title (tru "Invite team members") @@ -255,7 +265,7 @@ (defn- admin-checklist-values [] (map admin-checklist-entry - [:add-a-database :set-up-email :set-slack-credentials :invite-team-members :hide-irrelevant-tables + [:add-a-database :set-up-email :set-slack-credentials :set-telegram-credentials :invite-team-members :hide-irrelevant-tables :organize-questions :create-metrics :create-segments])) (defn- add-next-step-info diff --git a/src/metabase/api/telegram.clj b/src/metabase/api/telegram.clj new file mode 100644 index 0000000000000..af10cd193df23 --- /dev/null +++ b/src/metabase/api/telegram.clj @@ -0,0 +1,27 @@ +(ns metabase.api.telegram + "/api/telegram endpoints" + (:require [compojure.core :refer [PUT]] + [schema.core :as s] + [metabase.api.common :refer :all] + [metabase.config :as config] + [metabase.integrations.telegram :as telegram] + [metabase.models.setting :as setting] + [metabase.util.schema :as su])) + +(defendpoint PUT "/settings" + "Update Telegram related settings. You must be a superuser to do this." + [:as {{telegram-token :telegram-token, :as telegram-settings} :body}] + {telegram-token (s/maybe su/NonBlankString)} + (check-superuser) + (if-not telegram-token + (setting/set-many! {:telegram-token nil}) + (try + ;; just check that getMe doesn't throw an exception (a.k.a. that the token works) + (when-not config/is-test? + (telegram/GET :getMe, :exclude_archived 1, :token telegram-token)) + (setting/set-many! telegram-settings) + {:ok true} + (catch clojure.lang.ExceptionInfo info + {:status 400, :body (ex-data info)})))) + +(define-routes) diff --git a/src/metabase/integrations/telegram.clj b/src/metabase/integrations/telegram.clj new file mode 100644 index 0000000000000..a049734388a3e --- /dev/null +++ b/src/metabase/integrations/telegram.clj @@ -0,0 +1,59 @@ +(ns metabase.integrations.telegram + (:require [clojure.tools.logging :as log] + [cheshire.core :as json] + [clj-http.client :as http] + [metabase.models.setting :as setting :refer [defsetting]] + [metabase.util.i18n :refer [deferred-tru trs tru]] + [metabase.util.schema :as su] + [metabase.util :as u] + [schema.core :as s])) + +;; Define a setting which captures our Telegram api token +(defsetting telegram-token (deferred-tru "Telegram bot token obtained from https://t.me/BotFather")) + +(def ^:private ^:const ^String telegram-api-base-url "https://api.telegram.org/bot") + +(defn telegram-configured? + "Is Telegram integration configured?" + [] + (boolean (seq (telegram-token)))) + + +(defn- handle-response [{:keys [status body]}] + (let [body (json/parse-string body keyword)] + (if (and (= 200 status) (:ok body)) + body + (let [error (if (= (:error_code body) 401) + {:errors {:telegram-token "Invalid token"}} + {:message (str "Telegram API error: " (:error body)), :response body})] + (log/warn (u/pprint-to-str 'red error)) + (throw (ex-info (:message error) error)))))) + +(defn- do-telegram-request [request-fn params-key endpoint & {:keys [token], :as params, :or {token (telegram-token)}}] + (when token + (handle-response (request-fn (str telegram-api-base-url token "/" (name endpoint)) {params-key params + :conn-timeout 1000 + :socket-timeout 1000})))) + +(def ^{:arglists '([endpoint & {:as params}]), :style/indent 1} GET "Make a GET request to the Telegram API." (partial do-telegram-request http/get :query-params)) +(def ^{:arglists '([endpoint & {:as params}]), :style/indent 1} POST "Make a POST request to the Telegram API." (partial do-telegram-request http/post :form-params)) + +(def ^:private NonEmptyByteArray + (s/constrained + (Class/forName "[B") + not-empty + "Non-empty byte array")) + +(s/defn post-photo! + "Calls Telegram api `sendPhoto` method" + [file :- NonEmptyByteArray, text, chat-id :- su/NonBlankString] + {:pre [(seq (telegram-token))]} + (let [response (http/post (str telegram-api-base-url (telegram-token) "/sendPhoto") + {:multipart [{:name "chat_id", :content chat-id} + {:name "caption", :content text} + {:name "parse_mode", :content "MarkdownV2"} + {:name "photo", :content file}]})] + (if (= 200 (:status response)) + (u/prog1 (get-in (:body response) [:file :url_private]) + (log/debug "Uploaded image" <>)) + (log/warn "Error uploading file to Telegram:" (u/pprint-to-str response))))) diff --git a/src/metabase/models/pulse_channel.clj b/src/metabase/models/pulse_channel.clj index a33ebfd8e1be2..39b21de25f2c2 100644 --- a/src/metabase/models/pulse_channel.clj +++ b/src/metabase/models/pulse_channel.clj @@ -81,7 +81,8 @@ which contains any other relevant information for defining the channel. E.g. {:email {:name \"Email\", :recipients? true} - :slack {:name \"Slack\", :recipients? false}}" + :slack {:name \"Slack\", :recipients? false}} + :telegram {:name \"Telegram\", :recipients? false}}" {:email {:type "email" :name "Email" :allows_recipients true @@ -95,6 +96,15 @@ :type "select" :displayName "Post to" :options [] + :required true}]} + :telegram {:type "telegram" + :name "Telegram" + :allows_recipients false + :schedules [:hourly :daily :weekly :monthly] + :fields [{:name "chat-id" + :type "string" + :displayName "Post to" + :options [] :required true}]}}) (defn channel-type? diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj index e1fdb44b1fd93..d10bafc0348a4 100644 --- a/src/metabase/pulse.clj +++ b/src/metabase/pulse.clj @@ -5,6 +5,7 @@ [metabase.email :as email] [metabase.email.messages :as messages] [metabase.integrations.slack :as slack] + [metabase.integrations.telegram :as telegram] [metabase.models.card :refer [Card]] [metabase.models.dashboard :refer [Dashboard]] [metabase.models.dashboard-card :refer [DashboardCard]] @@ -221,6 +222,44 @@ (goal-comparison goal-val (comparison-col-rowfn row))) (get-in first-result [:result :data :rows])))) +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Telegram | +;;; +----------------------------------------------------------------------------------------------------------------+ + +(defn- telegram-title-url + "Returns dashboard or pulse url" + [{pulse-dashboard-id :dashboard_id, :as pulse} + dashboard] + (if dashboard + (params/dashboard-url (u/the-id dashboard) (params/parameters pulse dashboard)) + (if pulse-dashboard-id + (params/dashboard-url pulse-dashboard-id (params/parameters pulse dashboard)) + ;; For some reason, the pulse model does not contain the id + (urls/pulse-url 0)))) + +(defn- telegram-pulse-alert-message + "Sends a message via Telegram, using the image and the pulse/dashboard name and url, and the card name and url as a caption" + [{card-name :name, :as card} + {pulse-name :name, pulse-dashboard-id :dashboard_id, :as pulse} + dashboard] + (let [title-url (telegram-title-url pulse dashboard) + title-text (if dashboard (:name dashboard) pulse-name) + subtitle-url (urls/card-url (u/the-id card)) + subtitle-text card-name] + (format "[*%s*](%s) → [%s](%s)" title-text title-url subtitle-text subtitle-url))) + +(def telegram-width + "Width of the rendered png of html to be sent to Telegram." + 510) + +(defn post-telegram-message! + "Post a photo for a given Card by rendering its result into an image and sending it." + [card-results chat-id pulse dashboard] + (doall (for [{{card-id :id, card-name :name, :as card} :card, result :result, dashcard :dashcard} card-results] + (let [image-byte-array (render/render-pulse-card-to-png (defaulted-timezone card) card result telegram-width dashcard)] + (telegram/post-photo! image-byte-array + (telegram-pulse-alert-message card pulse dashboard) + chat-id))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | Creating Notifications To Send | @@ -259,7 +298,7 @@ (defmulti ^:private notification "Polymorphoic function for creating notifications. This logic is different for pulse type (i.e. alert vs. pulse) and - channel_type (i.e. email vs. slack)" + channel_type (i.e. email vs. slack vs. telegram)" {:arglists '([alert-or-pulse results channel])} (fn [pulse _ {:keys [channel_type]}] [(alert-or-pulse pulse) (keyword channel_type)])) @@ -290,6 +329,15 @@ (create-slack-attachment-data results) (when dashboard (slack-dashboard-footer pulse dashboard))]))})) +(defmethod notification [:pulse :telegram] + [{pulse-id :id, pulse-name :name, dashboard-id :dashboard_id, :as pulse} + results + {{chat-id :chat-id} :details}] + (log/debug (u/format-color 'cyan (trs "Sending Pulse ({0}: {1}) with {2} Cards via Telegram" + pulse-id (pr-str pulse-name) (count results)))) + (let [dashboard (Dashboard :id dashboard-id)] + {:chat-id chat-id :attachments results :pulse pulse :dashboard dashboard})) + (defmethod notification [:alert :email] [{:keys [id] :as pulse} results channel] (log/debug (trs "Sending Alert ({0}: {1}) via email" id name)) @@ -315,6 +363,13 @@ :emoji true}}]} (create-slack-attachment-data results))}) +(defmethod notification [:alert :telegram] + [pulse results {{chat-id :chat-id} :details}] + (log/debug (u/format-color 'cyan (trs "Sending Alert ({0}: {1}) via Telegram" (:id pulse) (:name pulse)))) + {:chat-id chat-id + :message (str "🔔 " (first-question-name pulse)) + :attachments results}) + (defmethod notification :default [_ _ {:keys [channel_type]}] (throw (UnsupportedOperationException. (tru "Unrecognized channel type {0}" (pr-str channel_type))))) @@ -350,16 +405,20 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ (defmulti ^:private send-notification! - "Invokes the side-effecty function for sending emails/slacks depending on the notification type" + "Invokes the side-effecty function for sending emails/slacks/telegrams depending on the notification type" {:arglists '([pulse-or-alert])} - (fn [{:keys [channel-id]}] - (if channel-id :slack :email))) + (fn [{:keys [channel-id chat-id], :as whatever}] + (if channel-id :slack (if chat-id :telegram :email)))) (defmethod send-notification! :slack [{:keys [channel-id message attachments]}] (let [attachments (create-and-upload-slack-attachments! attachments)] (slack/post-chat-message! channel-id message attachments))) +(defmethod send-notification! :telegram + [{:keys [chat-id attachments pulse dashboard]}] + (post-telegram-message! attachments chat-id pulse dashboard)) + (defmethod send-notification! :email [{:keys [subject recipients message-type message]}] (email/send-message! diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj index fc697fdfb352e..928579da915b8 100644 --- a/src/metabase/pulse/render.clj +++ b/src/metabase/pulse/render.clj @@ -194,8 +194,10 @@ (s/defn render-pulse-card-to-png :- bytes "Render a `pulse-card` as a PNG. `data` is the `:data` from a QP result (I think...)" - [timezone-id :- (s/maybe s/Str) pulse-card result width] - (png/render-html-to-png (render-pulse-card :inline timezone-id pulse-card nil result) width)) + ([timezone-id :- (s/maybe s/Str) pulse-card result width] + (render-pulse-card-to-png timezone-id pulse-card result width nil)) + ([timezone-id :- (s/maybe s/Str) pulse-card result width dashcard] + (png/render-html-to-png (render-pulse-card :inline timezone-id pulse-card nil result) width))) (s/defn png-from-render-info :- bytes "Create a PNG file (as a byte array) from rendering info."