Workflows: support CR variable substitution

Bug: twpowertools:91
Change-Id: Ib973bef40bed42d9c75f15710fd2ac3eeb6b9b15
diff --git a/src/contentScripts/communityConsole/workflows/actionRunners/replyWithCR.js b/src/contentScripts/communityConsole/workflows/actionRunners/replyWithCR.js
index e8f665c..e534ef9 100644
--- a/src/contentScripts/communityConsole/workflows/actionRunners/replyWithCR.js
+++ b/src/contentScripts/communityConsole/workflows/actionRunners/replyWithCR.js
@@ -6,6 +6,8 @@
 const kType_RecommendedAnswer = 3;
 const kPostMethodCommunityConsole = 4;
 
+const kVariablesRegex = /\$([A-Za-z_]+)/g;
+
 export default class CRRunner {
   constructor() {
     this._CRs = [];
@@ -36,31 +38,43 @@
     });
   }
 
+  _templateSubstitute(payload, thread) {
+    if (!payload.match(kVariablesRegex)) return Promise.resolve(payload);
+
+    return thread.loadThreadDetails().then(() => {
+      return payload.replaceAll(kVariablesRegex, (_, p1) => {
+        return thread?.[p1] ?? '';
+      });
+    });
+  }
+
   execute(action, thread) {
     let crId = action?.getCannedResponseId?.();
     if (!crId)
       return Promise.reject(
           new Error('The action doesn\'t contain a valid CR id.'));
 
-    return this._getCRPayload(crId).then(payload => {
-      let subscribe = action?.getSubscribe?.() ?? false;
-      let markAsAnswer = action?.getMarkAsAnswer?.() ?? false;
-      return CCApi(
-          'CreateMessage', {
-            1: thread.forum,   // forumId
-            2: thread.thread,  // threadId
-            // message
-            3: {
-              4: payload,
-              6: {
-                1: markAsAnswer ? kType_RecommendedAnswer : kType_Reply,
+    return this._getCRPayload(crId)
+        .then(payload => this._templateSubstitute(payload, thread))
+        .then(payload => {
+          let subscribe = action?.getSubscribe?.() ?? false;
+          let markAsAnswer = action?.getMarkAsAnswer?.() ?? false;
+          return CCApi(
+              'CreateMessage', {
+                1: thread.forumId,
+                2: thread.threadId,
+                // message
+                3: {
+                  4: payload,
+                  6: {
+                    1: markAsAnswer ? kType_RecommendedAnswer : kType_Reply,
+                  },
+                  11: kPostMethodCommunityConsole,
+                },
+                4: subscribe,
+                6: kPiiScanType_ScanNone,
               },
-              11: kPostMethodCommunityConsole,
-            },
-            4: subscribe,
-            6: kPiiScanType_ScanNone,
-          },
-          /* authenticated = */ true, getAuthUser());
-    });
+              /* authenticated = */ true, getAuthUser());
+        });
   }
 }
diff --git a/src/contentScripts/communityConsole/workflows/models/thread.js b/src/contentScripts/communityConsole/workflows/models/thread.js
new file mode 100644
index 0000000..0f54316
--- /dev/null
+++ b/src/contentScripts/communityConsole/workflows/models/thread.js
@@ -0,0 +1,113 @@
+import {waitFor} from 'poll-until-promise';
+
+import {CCApi} from '../../../../common/api.js';
+import {parseUrl} from '../../../../common/commonUtils.js';
+import {getAuthUser} from '../../../../common/communityConsoleUtils.js';
+
+export default class Thread {
+  constructor(forumId, threadId) {
+    this.forumId = forumId;
+    this.threadId = threadId;
+    this._details = null;
+  }
+
+  static fromUrl(url) {
+    const rawThread = parseUrl(url);
+    if (!rawThread) return null;
+
+    return new Thread(rawThread.forum, rawThread.thread);
+  }
+
+  loadThreadDetails() {
+    if (this._details) return Promise.resolve(true);
+
+    return waitFor(
+               () => {
+                 return CCApi(
+                            'ViewForum', {
+                              1: '0',  // forumID,
+                              // options
+                              2: {
+                                3: false,   // withMessages
+                                5: true,    // withUserProfile
+                                6: false,   // withUserReadState
+                                7: false,   // withStickyThreads
+                                9: false,   // withRequestorProfile
+                                10: false,  // withPromotedMessages
+                                11: false,  // withExpertResponder
+                                12: `forum:${this.forumId} thread:${
+                                    this.threadId}`,  // forumViewFilters
+                                16: false,            // withThreadNotes
+                                17: false,  // withExpertReplyingIndicator
+                              },
+                            },
+                            /* authenticated = */ true, getAuthUser())
+                     .then(res => {
+                       if (res?.['1']?.['2']?.length < 1)
+                         throw new Error(
+                             `Couldn't retrieve thread details (forum: ${
+                                 this.forumId}, thread: ${this.thread}).`);
+
+                       return res?.['1']?.['2']?.[0];
+                     });
+               },
+               {
+                 interval: 500,
+                 timeout: 2000,
+               })
+        .then(thread => {
+          this._details = thread;
+          return true;
+        });
+  }
+
+  get opName() {
+    return this._details?.['4']?.['1']?.['1'];
+  }
+
+  get opUserId() {
+    return this._details?.['4']?.['3'];
+  }
+
+  get forumTitle() {
+    return this._details?.['23'];
+  }
+
+  get isRead() {
+    return !!this._details?.['6'];
+  }
+
+  get isStarred() {
+    return !!this._details?.['7']?.['1'];
+  }
+
+  get numMessages() {
+    return this._details?.['8'];
+  }
+
+  get numAnswers() {
+    return this._details?.['15'];
+  }
+
+  get numSuggestedAnswers() {
+    return this._details?.['32'];
+  }
+
+  get title() {
+    return this._details?.['2']?.['9'];
+  }
+
+  get payload() {
+    return this._details?.['2']?.['13'];
+  }
+
+  // Accessors in the style of
+  // https://support.google.com/communities/answer/9147001.
+  get op_name() {
+    return this.opName;
+  }
+
+  get forum_name() {
+    return this.forumTitle;
+  }
+}
diff --git a/src/contentScripts/communityConsole/workflows/runner.js b/src/contentScripts/communityConsole/workflows/runner.js
index 2e90b19..d181d46 100644
--- a/src/contentScripts/communityConsole/workflows/runner.js
+++ b/src/contentScripts/communityConsole/workflows/runner.js
@@ -2,6 +2,7 @@
 import * as pb from '../../../workflows/proto/main_pb.js';
 
 import CRRunner from './actionRunners/replyWithCR.js';
+import Thread from './models/thread.js';
 
 export default class WorkflowRunner {
   constructor(workflow, updateCallback) {
@@ -32,7 +33,7 @@
       const url = recursiveParentElement(checkbox, 'EC-THREAD-SUMMARY')
                       .querySelector('a.header-content')
                       .href;
-      const thread = parseUrl(url);
+      const thread = Thread.fromUrl(url);
       if (!thread) {
         console.error('Couldn\'t parse URL ' + url);
         continue;
diff --git a/src/workflows/manager/components/actions/ReplyWithCR.js b/src/workflows/manager/components/actions/ReplyWithCR.js
index f1bdeeb..e3deee7 100644
--- a/src/workflows/manager/components/actions/ReplyWithCR.js
+++ b/src/workflows/manager/components/actions/ReplyWithCR.js
@@ -2,7 +2,7 @@
 import '@material/web/switch/switch.js';
 import '@material/web/textfield/outlined-text-field.js';
 
-import {css, html, LitElement} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
 import {createRef, ref} from 'lit/directives/ref.js';
 
 import {CCApi} from '../../../../common/api.js';
@@ -54,11 +54,13 @@
             ?readonly=${this.readOnly}
             @input=${this._cannedResponseIdChanged}>
         </md-outlined-text-field>
-        <md-outlined-button
-            icon="more"
-            label="Select CR"
-            @click=${this._openCRImporter}>
-        </md-outlined-button>
+        ${this.readOnly ? nothing : html`
+          <md-outlined-button
+              icon="more"
+              label="Select CR"
+              @click=${this._openCRImporter}>
+          </md-outlined-button>
+        `}
       </div>
       <div class="form-line">
         <md-formfield label="Subscribe to thread">