#!/usr/bin/python3 # -*- coding: utf-8 -*- # Copyright 2009-2011 Linnea Skogtvedt # Copyright 2016 Mike Gabriel # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the # Free Software Foundation, Inc., # 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA. import locale import re import sys import configparser from optparse import OptionParser from PyQt5.QtCore import * from PyQt5.QtGui import * from PyQt5.QtWidgets import QApplication, QDialog, QLabel, QPlainTextEdit, QDialogButtonBox, QGridLayout, QLineEdit, QCheckBox, QMessageBox class ConfigException(BaseException): pass class RapporterFeilDialog(QDialog): def __init__(self, title, userMsg, urlOpener, idleTimeout, customFields, onAccept, onTimeout): super(RapporterFeilDialog, self).__init__(None) def openLink(link): import shlex argv = shlex.split(urlOpener) + [link] QProcess.startDetached(argv[0], argv[1:]) def updateTitle(): assert isinstance(title, str) self.setWindowTitle(u'%s (%d...)' % (title, self.idleTimeout-self.idleCount)) def updateIdleCount(): self.idleCount += 1 updateTitle() if self.idleCount == self.idleTimeout: onTimeout() def resetIdleCount(ignored=None): """ Called when the user edits anything. Stops the idle timer and starts a timer to restart the idle timer after idleTimeout seconds. """ if idleTimeout == 0: return self.idleCount = 0 self.timer.stop() self.resetTimer.stop() self.resetTimer.start(self.idleTimeout*1000) self.setWindowTitle(title) msgLabel = QLabel(userMsg) msgLabel.setTextFormat(Qt.RichText) msgLabel.linkActivated.connect(openLink) self.msgTextEdit = QPlainTextEdit() self.msgTextEdit.textChanged.connect(resetIdleCount) buttonBox = QDialogButtonBox() buttonBox.addButton('Send', QDialogButtonBox.AcceptRole) buttonBox.addButton(QDialogButtonBox.Cancel) layout = QGridLayout() # w, row, col, rowspan, colspan layout.addWidget(msgLabel, 0, 0, 1, 2) self.idleTimeout = idleTimeout self.idleCount = 0 self.timer = QTimer() self.timer.setInterval(1000) self.timer.timeout.connect(updateIdleCount) if self.idleTimeout != 0: self.timer.start() self.resetTimer = QTimer() self.resetTimer.setSingleShot(True) self.resetTimer.timeout.connect(self.timer.start) row = 1 self.customFieldWidgets = [] # [ [label, widget] ] for label, initValue in customFields: if initValue in ('0|1', '1|0'): # checkbox checkbox = QCheckBox(label) checkbox.setChecked(initValue.split('|')[0] == '1') checkbox.stateChanged.connect(resetIdleCount) self.customFieldWidgets.append([checkbox, checkbox]) layout.addWidget(checkbox, row, 0, 1, 2) # span 1 row and 2 columns else: label, lineEdit = QLabel(label), QLineEdit(initValue) lineEdit.textChanged.connect(resetIdleCount) self.customFieldWidgets.append([label, lineEdit]) layout.addWidget(label, row, 0) layout.addWidget(lineEdit, row, 1) row += 1 layout.addWidget(self.msgTextEdit, row, 0, 5, 2) row += 5 layout.addWidget(buttonBox, row, 0, 1, 2) self.setLayout(layout) buttonBox.accepted.connect(onAccept) buttonBox.rejected.connect(self.reject) self.setWindowTitle(title) def get(self): ''' Return tuple (msg, customFields) where customFields is a list of (label, text) tuples. ''' def custom(): for label, widget in self.customFieldWidgets: if isinstance(widget, QCheckBox): yield label.text(), int(widget.isChecked()) else: yield label.text(), widget.text() return self.msgTextEdit.toPlainText(), list(custom()) def showConfirmation(self, title, msg): QMessageBox.information(self, title, msg) def showError(self, title, msg, exc): msg = '%s\n%s: %s' % (msg, type(exc).__name__, exc) QMessageBox.critical(self, title, msg) class LocalConfigParser(configparser.RawConfigParser): """ RawConfigParser subclass which expands shell $() syntax in values. In addition, option names are not case sensitive. >>> p = LocalConfigParser() >>> p.add_section('test') >>> p.set('test', 'Test æ', '$(echo "æ") $(echo "ø")') >>> p.set('test', 'test \xff', '') >>> p.items('test') [(u'Test \\xe6', u'\\xe6 \\xf8'), (u'test \ufffd', u'')] """ optionxform = str def _getoutput(self, cmd): import subprocess p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE) output = p.stdout.read().decode().rstrip() p.wait() return output def _expand(self, value): def replace(match): return self._getoutput(match.group(1)) assert isinstance(value, str) return re.sub('\$\((.*?)\)', replace, value) def get(self, section, option): return self._expand(configparser.RawConfigParser.get(self, section, option)) def items(self, section): return [ (k, self._expand(v)) for (k, v) in configparser.RawConfigParser.items(self, section) ] def createEmail(fromAddr, toAddrs, ccAddrs, subject, msg, custom, info): """ Create email message, returned in plain text and MIME encoded. >>> t, m = createEmail(u'from@example.org', [u'to@example.org'], None, u'Test', u'', ... [(u'a', u'\xe5')], [(u'c', u'd')]) >>> assert 'a: å' in t >>> assert 'From: from@example.org' in m """ assert isinstance(msg, str) emailText = msg + '\n\n' + \ '\n'.join(['%s: %s' % (f, v) for (f,v) in info]) + '\n' + \ '\n'.join(['%s: %s' % (f, v) for (f,v) in custom]) import email.mime.text emailText = emailText m = email.mime.text.MIMEText(emailText, 'plain', 'utf-8') m.add_header('From', fromAddr) m.add_header('To', ', '.join(toAddrs)) if ccAddrs: m.add_header('Cc', ', '.join(ccAddrs)) m.add_header('Subject', subject) return (emailText, m.as_string()) def sendEmail(fromAddr, toAddrs, smtpServer, emailMime): """ Send the email. >>> from minimock import Mock, restore >>> import smtplib >>> smtplib.SMTP = Mock('smtplib.SMTP') >>> smtplib.SMTP.mock_returns = Mock('smtp_connection') >>> sendEmail(u'from@example.org', [u'to@example.org'], u'smtp.example.org', 'body') Called smtplib.SMTP('smtp.example.org') Called smtp_connection.sendmail('from@example.org', ['to@example.org'], 'body') Called smtp_connection.quit() >>> """ fromAddr = fromAddr toAddrs = toAddrs smtpServer = smtpServer import smtplib server = smtplib.SMTP(smtpServer) server.sendmail(fromAddr, toAddrs, emailMime) server.quit() def exportFields(fields): """ Export the fields to the environment, prepending 'RF_' to the variable name and encoding variable and value to utf-8. Everything not matching [A-Z] is stripped from the variable name. >>> exportFields([[u'Te09_.\xe5st', u'\xe5']]) >>> import os >>> os.environ['RF_TEST'] '\\xc3\\xa5' >>> """ import os import re for k, v in fields: k = 'RF_' + re.sub(r'[^A-Z]', '', k.upper()) os.environ[k] = str(v) def runCallbacks(callbacks, input): """ Run commands, writing input to their stdin. >>> import os >>> pipe_r, pipe_w = os.pipe() >>> runCallbacks(['cat >&%d' % pipe_w], 'å') >>> os.read(pipe_r, 4096) '\\xc3\\xa5' """ def runCallback(cmd, input): assert isinstance(cmd, str) import subprocess print ("CALLBACK: ", cmd, input) p = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE) try: p.stdin.write(bytes(input, 'UTF-8')) except(IOError): pass p.stdin.close() p.wait() assert isinstance(input, str) for cmd in callbacks: runCallback(cmd, input) class RapporterFeil(object): dialogcls = RapporterFeilDialog def __init__(self): self.config_tips = LocalConfigParser() self.config_rapporter = LocalConfigParser() self.dialog = None import sys self.app = QApplication(sys.argv) locale = QLocale.system().name() self.qtTranslator = QTranslator() if self.qtTranslator.load('qt_%s' % locale, '/usr/share/qt5/translations'): self.app.installTranslator(self.qtTranslator) def main(self): parser = OptionParser(usage="usage: %prog [options]") parser.add_option('-t', '--timeout', help='idle timeout in seconds', metavar='SECONDS', type='int', default=0) parser.add_option('-c', '--cfg-tips', help='configuration file for user hints', metavar='FILE', type='string', default='/etc/tips.cfg') parser.add_option('-r', '--cfg-rapporter', help='configuration file for error reporting', metavar='FILE', type='string', default='/etc/rapporter-feil.cfg') options, args = parser.parse_args() if args: parser.error('no positional arguments allowed') self.config_tips.readfp(open(options.cfg_tips, 'r')) self.config_rapporter.readfp(open(options.cfg_rapporter, 'r')) lang, encoding = locale.getlocale() lang, region = lang.split("_") title_options = [ 'title[{lang}_{region}]'.format(lang=lang, region=region), 'title[{lang}]'.format(lang=lang), 'title', ] for option in title_options: if self.config_rapporter.has_option('rapporter-feil', option): title = self.config_rapporter.get('rapporter-feil', option) break else: raise ConfigException msg_options = [ 'msg[{lang}_{region}]'.format(lang=lang, region=region), 'msg[{lang}]'.format(lang=lang), 'msg', ] for option in msg_options: if self.config_tips.has_option('tips', option): msg = self.config_tips.get('tips', option) break else: raise ConfigException custom_fields_section = 'custom_fields:{lang}_{region}'.format(lang=lang, region=region) if not self.config_rapporter.has_section(custom_fields_section): custom_fields_section = 'custom_fields:{lang}'.format(lang=lang) if not self.config_rapporter.has_section(custom_fields_section): custom_fields_section = 'custom_fields' customFields = self.config_rapporter.items(custom_fields_section) try: urlOpener = self.config_tips.get('tips', 'open_urls_with') except(configparser.NoOptionError): urlOpener = 'xdg-open' self.dialog = self.dialogcls(title, msg, urlOpener, options.timeout, customFields, self.onAccept, self.onTimeout) self.dialog.show() self.app.exec_() def onAccept(self): lang, encoding = locale.getlocale() lang, region = lang.split("_") msg, customFields = self.dialog.get() if not msg: return exportFields(customFields) info_section = 'info:{lang}_{region}'.format(lang=lang, region=region) if not self.config_rapporter.has_section(info_section): info_section = 'info:{lang}'.format(lang=lang) if not self.config_rapporter.has_section(info_section): info_section = 'info' exportFields(self.config_rapporter.items(info_section)) fromAddr = self.config_rapporter.get('email', 'from_address') toAddrs = self.config_rapporter.get('email', 'to_addresses').split() try: ccAddrs = self.config_rapporter.get('email', 'cc_addresses').split() except(configparser.NoOptionError): ccAddrs = [] try: bccAddrs = self.config_rapporter.get('email', 'bcc_addresses').split() except configparser.NoOptionError: bccAddrs = [] title_options = [ 'title[{lang}_{region}]'.format(lang=lang, region=region), 'title[{lang}]'.format(lang=lang), 'title', ] for option in title_options: if self.config_rapporter.has_option('rapporter-feil', option): title = self.config_rapporter.get('rapporter-feil', option) break else: raise ConfigException errormsg_options = [ 'error_msg[{lang}_{region}]'.format(lang=lang, region=region), 'error_msg[{lang}]'.format(lang=lang), 'error_msg', ] for option in errormsg_options: if self.config_rapporter.has_option('rapporter-feil', option): error_msg = self.config_rapporter.get('rapporter-feil', option) break else: raise ConfigException confirmmsg_options = [ 'confirmation_msg[{lang}_{region}]'.format(lang=lang, region=region), 'confirmation_msg[{lang}]'.format(lang=lang), 'confirmation_msg', ] for option in confirmmsg_options: if self.config_rapporter.has_option('rapporter-feil', option): confirmation_msg = self.config_rapporter.get('rapporter-feil', option) break else: raise ConfigException subject_options = [ 'subject[{lang}_{region}]'.format(lang=lang, region=region), 'subject[{lang}]'.format(lang=lang), 'subject', ] for option in subject_options: if self.config_rapporter.has_option('email', option): subject = self.config_rapporter.get('email', option) break else: raise ConfigException smtpServer = self.config_rapporter.get('email', 'smtp_server') print (info_section) emailText, emailMime = createEmail(fromAddr, toAddrs, ccAddrs, subject, msg, customFields, self.config_rapporter.items(info_section)) plain_callbacks_section = 'plain_callbacks:{lang}_{region}'.format(lang=lang, region=region) if not self.config_rapporter.has_section(plain_callbacks_section): plain_callbacks_section = 'plain_callbacks:{lang}'.format(lang=lang) if not self.config_rapporter.has_section(plain_callbacks_section): plain_callbacks_section = 'plain_callbacks' mime_callbacks_section = 'mime_callbacks:{lang}_{region}'.format(lang=lang, region=region) if not self.config_rapporter.has_section(mime_callbacks_section): mime_callbacks_section = 'mime_callbacks:{lang}'.format(lang=lang) if not self.config_rapporter.has_section(mime_callbacks_section): mime_callbacks_section = 'mime_callbacks' for section, data in ((plain_callbacks_section, emailText), (mime_callbacks_section, emailMime)): commands = [ cmd for (ign, cmd) in self.config_rapporter.items(section)] runCallbacks(commands, data) if smtpServer: try: sendEmail(fromAddr, toAddrs + ccAddrs + bccAddrs, smtpServer, emailMime) except Exception as e: self.dialog.showError(title, error_msg, e) return self.dialog.showConfirmation(title, confirmation_msg) self.app.exit() def onTimeout(self): self.app.exit() if __name__ == '__main__': RapporterFeil().main()