Wednesday, June 5, 2019

Excel VBA: Automatic macro execution for Windows Task Scheduler (and a few pranks to boot)

Despite all modern trends towards workflow automation, manual Excel-based workflows still abound in many areas of industry, notably finance. In our previous post we learned how to simplify and automate some of them, reducing manual copy-and-paste to a button push.

The next step of this would be further automation, so that the"button" gets pushed automatically, for example on a daily basis at a predetermined time.

One intuitive approach would be to invoke your code using an Auto Macro such as Workbook_Open, and then have Windows Task Scheduler open your workbook on schedule as required.

However, there is a catch here. Workbooks used for automated workflow are very often also used for ad-hoc manual tasks, or are simply opened to view some data, with no intention of performing any (re)calculations. Now your auto macro code would run every time the workbook is opened. If the automated workflow involves some data modification and/or email notification, you will have side effects very time you open your workbook just to see what is in there.

Worse still, if your workbook is on a shared drive, other used may stumble upon it by accident, triggering execution of the auto macros when they never expect it.

Of course, you could run two copies of your spreadsheet - one for automation and one for manual inquiries, naming the "automation" one something like "never_ever_open_this_manually.xlsm", but what happens in practice in such cases is that the two workbooks fall out of sync, and one of them is always out of date (or worse, they are both partially out of date).

A much more elegant solution would be for the auto macro to detect that the workbook was invoked by the Task Scheduler and not manually, and run the workflow only in this case.

After some probing and looking for inspiration I have come up with this solution:

Private Function IsScheduled() As Boolean
 ' Determine if the workbook was opened manually or as part of a Task Scheduler routine
 On Error GoTo Decided
 Dim Result As Boolean
 Result = True ' An invisible or programmatically started session is always assumed scheduled
  If Not Application.Visible Then GoTo Decided
  If Not Application.UserControl Then GoTo Decided
 Result = False ' A session with more that one workbooks is always assumed manual
  If Application.Workbooks.Count > 1 Then GoTo Decided
 ' otherwise assume scheduled task if the workbook name was supplied in the command line
 Dim wname As String, cmdline As String
 cmdline = UCase(Trim(GetCommandLine)): wname = UCase(Trim(ThisWorkbook.Name))
 Result = InStr(cmdline, wname) > 0
Decided: IsScheduled = Result
 On Error GoTo 0
End Function

Private Sub Workbook_Open()
 If Is Scheduled Then
  ' Run your automated workflow here
 End If
End Sub

It basically relies on the discovery that if a workbook is started by the Task Scheduler, its command line would be
C:\Program Files\OFFICE##\Excel.exe v:\path_to_workbook\workbook.xlsm
whereas for most other scenarios of workbook opening (directly from Excel or from Windows Explorer) the command line would look like
C:\Program Files\OFFICE##\Excel.exe
or
C:\Program Files\OFFICE##\Excel.exe /dde
i.e. would not contain the workbook name (instead, it passes the workbook via DDE)

To get to the command line, we need to implement the following API function (taken from here):

#If Win64 Then
    Private Declare PtrSafe Function GetCommandLineL Lib "kernel32" Alias "GetCommandLineA" () As LongPtr
    Private Declare PtrSafe Function lstrcpyL Lib "kernel32" Alias "lstrcpyA" (ByVal lpString1 As String, ByVal lpString2 As LongPtr) As Long
    Private Declare PtrSafe Function lstrlenL Lib "kernel32" Alias "lstrlenA" (ByVal lpString As LongPtr) As Long
#Else
    Private Declare Function GetCommandLineL Lib "kernel32" Alias "GetCommandLineA" () As Long
    Private Declare Function lstrcpyL Lib "kernel32" Alias "lstrcpyA" (ByVal lpString1 As String, ByVal lpString2 As Long) As Long
    Private Declare Function lstrlenL Lib "kernel32" Alias "lstrlenA" (ByVal lpString As Long) As Long
#End If

Private Function GetCommandLine() As String
GetCommandLine = "FAILED TO RETRIEVE" ' fallback value is case of error
On Error GoTo Finally ' suppress errors
   Dim strReturn As String
   #If Win64 Then
    Dim lngPtr As LongPtr
   #Else
    Dim lngPtr As Long
   #End If
   Dim StringLength As Long
   'Get the pointer to the commandline string
   lngPtr = GetCommandLineL
   'get the length of the string (not including the terminating null character):
   StringLength = lstrlenL(lngPtr)
   'initialize our string so it has enough characters including the null character:
   strReturn = String$(StringLength + 1, 0)
   'copy the string we have a pointer to into our new string:
   lstrcpyL strReturn, lngPtr
   'now strip off the null character at the end:
   GetCommandLine = Left$(strReturn, StringLength)
Finally: On Error GoTo 0
End Function

Note the compiler directives to make the code 32/64-bit aware -- this is absolutely essential if the workbook resides on a shared drive in a network with mixed 32/64-bit Excel installation. Otherwise opening the workbook might crash the entire Excel session for random unsuspecting users.


BONUS: When debugging the automation routine I have stumbled upon some wonderful tools you can use to play a practical joke on your peers. Namely you can use Application.OnKey to override the function of a commonly used key (like, a letter or an arrow) to something totally bizarre, and wrap it into Application.OnTime to activate at some (possibly random) point in the future. Put it in your auto macro, like so:

Public num As Integer

Function sov(sekunder As Double) As Double
starting_time = Timer
Do
 DoEvents
Loop Until (Timer - starting_time) >= sekunder
End Function

Sub GetDizzy()
num = num + 1
On Error Resume Next
Select Case (num Mod 5)
Case 2 'move to opposite drection
 ActiveCell.Offset(0, 1).Select 
Case 3 'shake the workbook
 dx = Round(Rnd * 10) - 5: dy = Round(Rnd * 10) - 5
 For i = 1 To 3
  ActiveWindow.SmallScroll toright:=dx, down:=dy: sov (50 / 1000)
  ActiveWindow.SmallScroll toleft:=dx, up:=dy: sov (50 / 1000)
 Next i
Case 4 'nudge the workbook
 ActiveCell.ColumnWidth = ActiveCell.ColumnWidth + Round(Rnd * 15) - 7
Case Else 'normal operation
 ActiveCell.Offset(0, -1).Select 
End Select
If num > 200 Then MsgBox "You are tired and dizzy... Why don't you call it a day and go home?"
On Error GoTo 0
End Sub

Sub Payload()
 Application.OnKey "{LEFT}", "GetDizzy"
End Sub

Private Sub Workbook_Open()
Application.OnTime Now + TimeValue("8:00:00"), "Payload"
End Sub


and watch your colleagues of choice get delighted at the emphatic computer offering them to go home at the end of the working day because they are tired and have worked long hours and it's late outside (I've borrowed the sleep function sov() from here). The code is very hard to detect because it executes totally silently and persists even after the workbook containing has long been closed; the empathy will last until Excel is closed or restarted -- but in most industry workplaces I have seen, this only happens when it crashes.

Your mileage may actually vary with the payload, but do remember not to use it for any real sabotage because this is a surefire way to get fired, if you excuse the tautology again.


No comments:

Post a Comment