a51a9e2eec975f01419e03107d70c74dfe5e0766
hiram
  Tue Apr 28 14:35:28 2026 -0700
different response messages for liftRequest vs. assembly request refs #31811

diff --git src/hg/utils/otto/userRequests/ottoRequest.py src/hg/utils/otto/userRequests/ottoRequest.py
index e29b9bc9d87..2df6933c138 100755
--- src/hg/utils/otto/userRequests/ottoRequest.py
+++ src/hg/utils/otto/userRequests/ottoRequest.py
@@ -2,101 +2,174 @@
 """ottoRequest.py - check ottoRequest table for pending requests
 and send email notification for each one found.
 
 Intended to run from cron.  Reads the notification email address and
 table name from an hg.conf file.  Uses hgsql for database access.
 
 Usage:
     ottoRequest.py [-c /path/to/hg.conf]
 
 Options:
     -c, --conf    Path to hg.conf  [default: /usr/local/apache/cgi-bin/hg.conf]
 """
 
 import argparse
 import os
+import re
 import subprocess
 import sys
 
+NOTIFY_FROM = 'genome-www@soe.ucsc.edu'
+BCC_BY_TYPE = {
+    'liftOver': 'chain-file-request-group@ucsc.edu',
+    'assembly': 'genark-request-group@ucsc.edu',
+}
+
 
 def parseHgConf(path):
     """Return a dict of key=value pairs from an hg.conf file.
     Handles 'include' directives with paths relative to the
     directory of the file containing the include."""
     conf = {}
     confDir = os.path.dirname(os.path.abspath(path))
     try:
         with open(path) as fh:
             for line in fh:
                 line = line.strip()
                 if not line or line.startswith('#'):
                     continue
                 if line.startswith('include '):
                     inclPath = line.split(None, 1)[1]
                     if not os.path.isabs(inclPath):
                         inclPath = os.path.join(confDir, inclPath)
                     inclConf = parseHgConf(inclPath)
                     conf.update(inclConf)
                     continue
                 if '=' in line:
                     key, value = line.split('=', 1)
                     conf[key.strip()] = value.strip()
     except FileNotFoundError:
         sys.exit(f"Error: config file not found: {path}")
     except PermissionError:
         sys.exit(f"Error: cannot read config file: {path}")
     return conf
 
 
+def unescapeMysql(s):
+    """Reverse mysql -B batch-mode escaping: \\n -> newline,
+    \\t -> tab, \\\\ -> backslash, \\0 -> NUL.  Single pass so
+    \\\\n stays a literal backslash followed by 'n'."""
+    out = []
+    i = 0
+    n = len(s)
+    while i < n:
+        if s[i] == '\\' and i + 1 < n:
+            c = s[i+1]
+            if c == 'n':
+                out.append('\n')
+            elif c == 't':
+                out.append('\t')
+            elif c == '\\':
+                out.append('\\')
+            elif c == '0':
+                out.append('\0')
+            else:
+                out.append(s[i:i+2])
+            i += 2
+        else:
+            out.append(s[i])
+            i += 1
+    return ''.join(out)
+
+
 def hgsqlQuery(db, sql):
-    """Run a SQL query via hgsql and return rows as list of dicts."""
+    """Run a SQL query via hgsql and return rows as list of dicts.
+    hgsql -B emits tabs/newlines/backslashes inside field values as
+    literal \\t / \\n / \\\\ so each row stays on one line undo that
+    on each field before returning."""
     cmd = ['hgsql', db, '-N', '-B', '-e', sql]
     result = subprocess.run(cmd, capture_output=True, text=True)
     if result.returncode != 0:
         sys.exit(f"hgsql error: {result.stderr.strip()}")
     rows = []
     if not result.stdout.strip():
         return rows
     for line in result.stdout.strip().split('\n'):
-        fields = line.split('\t')
+        fields = [unescapeMysql(f) for f in line.split('\t')]
         rows.append({
             'id':          fields[0],
             'requestType': fields[1],
             'fromDb':      fields[2],
             'toDb':        fields[3],
             'email':       fields[4],
             'comment':     fields[5],
             'requestTime': fields[6],
         })
     return rows
 
 
 def hgsqlUpdate(db, sql):
     """Run a SQL update/insert statement via hgsql."""
     cmd = ['hgsql', db, '-N', '-B', '-e', sql]
     result = subprocess.run(cmd, capture_output=True, text=True)
     if result.returncode != 0:
         print(f"hgsql update error: {result.stderr.strip()}", file=sys.stderr)
         return False
     return True
 
 
-def sendMail(toAddr, subject, body, fromAddr=None):
+def parseAssemblyComment(comment):
+    """Assembly requests pack fields into the ottoRequest.comment column
+    via hubApi/findGenome.c:apiAssemblyRequest:
+        name: '<name>'[; betterName: '<bn>'][; comment: '<user comment>']
+    The user comment may itself contain newlines and apostrophes, so the
+    inner quote pair is matched greedily up to the trailing closing quote.
+    re.DOTALL lets '.' span newlines.
+    Return (name, betterName, userComment)."""
+    name = ''
+    betterName = ''
+    userComment = ''
+
+    m = re.match(r"name: '([^']*)'(.*)$", comment, re.DOTALL)
+    if not m:
+        return name, betterName, comment
+    name = m.group(1)
+    rest = m.group(2)
+
+    m2 = re.match(r"; betterName: '([^']*)'(.*)$", rest, re.DOTALL)
+    if m2:
+        betterName = m2.group(1)
+        rest = m2.group(2)
+
+    m3 = re.match(r"; comment: '(.*)'\s*$", rest, re.DOTALL)
+    if m3:
+        userComment = m3.group(1)
+    else:
+        userComment = rest.lstrip('; ')
+
+    return name, betterName, userComment
+
+
+def sendMail(toAddr, subject, body, fromAddr=None, bccAddr=None):
     """Send email via /usr/sbin/sendmail.
     If fromAddr is provided it is used as the envelope sender (-f)
-    and the From: header so that bounces return to that address."""
+    and the From: header so that bounces return to that address.
+    If bccAddr is provided, sendmail -t reads it from the header,
+    delivers a copy, and strips the Bcc: line before transmission."""
     headers = f"To: {toAddr}\nSubject: {subject}"
+    if bccAddr:
+        headers = f"Bcc: {bccAddr}\n{headers}"
     if fromAddr:
         headers = (f"From: {fromAddr}\n"
                    f"Reply-To: {fromAddr}\n"
                    f"Return-Path: {fromAddr}\n"
                    f"{headers}")
     message = f"{headers}\n\n{body}\n"
     cmd = ['/usr/sbin/sendmail', '-t']
     if fromAddr:
         cmd += ['-f', fromAddr]
     result = subprocess.run(cmd, input=message, capture_output=True, text=True)
     if result.returncode != 0:
         print(f"Warning: sendmail failed: {result.stderr.strip()}",
               file=sys.stderr)
         return False
     return True
@@ -106,79 +179,84 @@
     parser = argparse.ArgumentParser(
         description='Process pending ottoRequest entries.')
     parser.add_argument('-c', '--conf',
                         default='/usr/local/apache/cgi-bin/hg.conf',
                         help='Path to hg.conf [default: %(default)s]')
     args = parser.parse_args()
 
     conf = parseHgConf(args.conf)
 
     dbName = conf.get('central.db')
     if not dbName:
         sys.exit("Error: central.db not defined in config")
 
     table = conf.get('ottoTable', 'ottoRequest')
 
-    notifyEmail = conf.get('chainFileRequestEmail')
-    if not notifyEmail:
-        sys.exit("Error: chainFileRequestEmail not defined in config")
-
     # find pending requests
     sql = (f"SELECT id, requestType, fromDb, toDb, email, comment, "
            f"requestTime FROM {table} WHERE status = 0")
     pending = hgsqlQuery(dbName, sql)
 
     if not pending:
         return  # nothing to do -- silent for cron
 
     for req in pending:
-        subject = f"ottoRequest #{req['id']}: {req['requestType']} pending"
-        body = (
-            f"##################################################\n"
-            f"Pending {req['requestType']} request #{req['id']}\n"
-            f"\n"
-            f"  From:    {req['fromDb']}\n"
-            f"  To:      {req['toDb']}\n"
-            f"  Email:   {req['email']}\n"
-            f"  Comment: {req['comment']}\n"
-            f"  Time:    {req['requestTime']}\n"
-            f"##################################################\n"
-            f"testing ottoRequest watch cron job\n"
-            f"##################################################\n"
-        )
-        if sendMail(notifyEmail, subject, body, fromAddr=notifyEmail):
-#           print(f"Notified {notifyEmail} about request #{req['id']}")
-            hgsqlUpdate(dbName,
-                f"UPDATE {table} SET status = 1"
-                f" WHERE id = {req['id']}")
-        else:
-            print(f"Failed to notify about request #{req['id']}",
+        reqType = req['requestType']
+        bccAddr = BCC_BY_TYPE.get(reqType)
+        if not bccAddr:
+            print(f"Warning: unknown requestType '{reqType}' for"
+                  f" request #{req['id']}, skipping",
                   file=sys.stderr)
+            continue
 
-        # send acknowledgment to the requesting user
         userEmail = req.get('email', '')
-        if userEmail:
-            userSubject = (f"UCSC Genome Browser: your {req['requestType']}"
+        if not userEmail:
+            print(f"Warning: no user email for request #{req['id']},"
+                  f" skipping", file=sys.stderr)
+            continue
+
+        subject = (f"UCSC Genome Browser: your {reqType}"
                    f" request has been received")
-            userBody = (
-                f"Your request for chain/liftOver files has been received\n"
-                f"and is being processed.\n"
+        if reqType == 'assembly':
+            name, betterName, userComment = parseAssemblyComment(req['comment'])
+            body = (
+                f"Your assembly request has been received and is being\n"
+                f"processed.\n"
+                f"\n"
+                f"name: '{name}'\n"
+                f"email: '{userEmail}'\n"
+                f"asmId: '{req['fromDb']}'\n"
+                f"betterName: '{betterName}'\n"
+                f"comment: '{userComment.rstrip()}'\n"
+                f"date: '{req['requestTime']}'\n"
+                f"\n"
+                f"Will advise when this assembly is available in the genome browser.\n"
+                f"\n"
+                f"-- UCSC Genome Browser\n"
+            )
+        else:
+            body = (
+                f"Your alignment request has been received and is being\n"
+                f"processed.\n"
                 f"\n"
                 f"Request details:\n"
-                f"  Type:      {req['requestType']}\n"
                 f"  From:      {req['fromDb']}\n"
                 f"  To:        {req['toDb']}\n"
+                f"  Comment:   {req['comment'].rstrip()}\n"
                 f"  Submitted: {req['requestTime']}\n"
                 f"\n"
-                f"You will receive another email when your files are ready.\n"
+                f"Will advise when this alignment is available in the genome browser.\n"
                 f"\n"
-                f"- UCSC Genome Browser\n"
+                f"-- UCSC Genome Browser\n"
             )
-            if not sendMail(userEmail, userSubject, userBody,
-                           fromAddr=notifyEmail):
-                print(f"Warning: failed to send acknowledgment to"
-                      f" {userEmail} for request #{req['id']}",
+        if sendMail(userEmail, subject, body,
+                    fromAddr=NOTIFY_FROM, bccAddr=bccAddr):
+            hgsqlUpdate(dbName,
+                f"UPDATE {table} SET status = 1"
+                f" WHERE id = {req['id']}")
+        else:
+            print(f"Failed to send notification for request #{req['id']}",
                   file=sys.stderr)
 
 
 if __name__ == '__main__':
     main()