通过谷歌服务安排邮寄活动

Andrei Novichkov | 6 八月, 2019

简介

交易者可能希望安排一次邮寄活动,以维持与其他交易者、订户、客户或朋友的业务关系。此外,可能需要发送截图、日志或报告。这些任务可能不是最经常出现的任务,但是拥有这样的特性显然是一个优势。在这里使用方便的MQL工具绝对是困难的,甚至是完全不可能的。在本文的最后,我们将回到使用专门的MQL工具来解决此任务的问题。在此之前,我们将使用MQL和C#的组合。这将使我们相对容易地编写必要的代码并将其连接到终端,此外,它还将设置一个与此连接相关的非常有趣的挑战。

本文面向初学者和中层开发人员,他们希望加深他们对编写库的知识,并将它们与终端集成,同时更加熟悉Google服务。

设置任务

现在,让我们更准确地定义我们将要做什么,有一个可更新的联系人列表,允许用户向列表中的任何联系人发送一次或多次带有附件的电子邮件。需要考虑的事项:

实施列表管理是最明显的任务。我们这里有什么选择?

  1. HDD 数据库或 CSV 文件不方便且不够可靠。它并不总是可用的,可能需要一个额外的软件来管理这样的存储。
  2. 一个特殊网站的数据库,有一个Joomla类型的CMS,这是一个很好的解决方案。数据受到保护,可以从任何地方访问。此外,电子邮件可以很容易地从网站发送。然而,也有一个显著的缺点。与此类网站交互需要一个特殊的附加组件,这样的附加组件可能非常大,并且充满了安全漏洞。换句话说,这里必须有可靠的基础设施。
  3. 使用现成的 Google 服务。在那里,您可以安全地存储和管理联系人,以及从不同的设备访问他们。尤其是,您可以形成各种列表(组)并发送电子邮件。这就是我们舒适工作所需要的一切,所以我们坚持这个选择。

与 Google 交互有大量的文档记录,例如这里。要开始使用Google,请注册一个帐户并在那里创建联系人列表,该列表应包含我们要向其发送电子邮件的联系人。在“联系人”中,创建具有特定名称的组,例如“Forex”,并将所选联系人添加到该组中。每个联系人都可以保存多个数据以备以后使用。不幸的是,如果用户仍然需要一个额外的数据字段,则无法创建它,这不会造成不便,因为有很多数据字段可用,稍后我将演示如何使用它们,

现在是时候开始主要任务了。

谷歌方面的准备工作

假设我们已经有一个谷歌帐户,使用谷歌“开发控制台”恢复项目开发。在这里您可以详细了解如何使用控制台和开发项目,当然,上面的链接将介绍另一个项目。我们的项目需要一个名字,让它名为 " WorkWithPeople"。我们还需要其他服务,在此阶段,启用以下选项:

第一个提供了对联系人列表的访问(实际上,它也提供了对其他事物的访问,但我们只需要列表)。有另一个用于访问联系人列表的服务-Contacts API,但目前不建议使用,因此我们不注意它。

顾名思义,第二个服务提供对邮件的访问。

启用服务并获取授予应用程序访问它们的密钥。不需要写下来或者记住,以 json 格式下载附件,其中包含访问 Google 资源所需的所有数据,包括这些密钥。将文件保存在磁盘上,也许可以给它一个更有意义的名称。在我的例子中,它被称为“WorkwithPeople_gmail.json”。这就完成了与谷歌的直接操作,我们已经创建了帐户、联系人列表和项目,并获得了访问文件。

现在,让我们继续在 VS 2017 工作。

项目和开发包

打开 VS 2017并创建一个标准Class Library(.NET Framework)项目。以任何可记忆的方式命名它(在我的例子中,它与Google项目名称“WorkwithPeople”一致,尽管这不是必须的)。使用NuGet立即安装其他软件包:

在安装过程中,Nuget提供安装相关软件包的服务,要对此表示同意。在我们的例子中,该项目接收谷歌的软件包,用于处理联系人和管理电子邮件。现在我们准备好开发代码了。

访问联系人

让我们从辅助类开始,如果我们考虑到某个谷歌联系人所包含的数据量,那么很明显,我们的任务不需要它的主要部分,我们需要一个联系人姓名和地址发送电子邮件。事实上,我们还需要来自另一个栏位的数据,但稍后会讨论更多。

相应的类可以如下所示:

namespace WorkWithPeople
{    
    internal sealed class OneContact
    {
        public OneContact(string n, string e)
        {
            this.Name  = n;
            this.Email = e;
        }
        public string Name  { get; set; }
        public string Email { get; set; }
    }
}

有两个“字符串”类型的属性存储联系人姓名和地址,以及一个简单的构造函数,其中包含两个参数来初始化它们,没有实现另外的检查,它们将在其它地方进行。

读取联系人列表时会创建简单元素列表。这允许根据这个新构建的列表的数据进行邮件活动。如果要更新列表,请删除所有列表元素,然后重复从Google帐户读取和选择数据的操作。

还有另一个辅助类,联系人列表可能包含无效的电子邮件地址,或者根本没有地址,在发送电子邮件之前,我们需要确保地址正确无误,让我们开发新的辅助类来实现这一点:

namespace WorkWithPeople
{    
    internal static class ValidEmail
    {
        public stati cbool IsValidEmail(this string source) => !string.IsNullOrEmpty(source) && new System.ComponentModel.DataAnnotations.EmailAddressAttribute().IsValid(source);
    }
}

为了执行检查,我们使用可用的工具,尽管我们也可以使用正则表达式。为了便于进一步使用,我们开发了代码作为扩展方法。由于不难猜测,如果包含邮件地址的字符串通过检查,则该方法返回true,否则返回false。现在是时候转到代码的主要部分了。

访问和使用服务

我们已经创建了项目,获取了密钥,并下载了用于授权应用程序的JSON文件。因此,让我们创建一个新的类ContactsPeople并将适当的程序集添加到文件中:

using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Net.Mail;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Google.Apis.Auth.OAuth2;
using Google.Apis.People.v1;
using Google.Apis.Services;
using Google.Apis.Util.Store;
using Google.Apis.Http;
using Google.Apis.PeopleService.v1;
using Google.Apis.PeopleService.v1.Data;
using Google.Apis.Gmail.v1;
using Google.Apis.Gmail.v1.Data;

namespace WorkWithPeople
{
    internal sealed class ContactsPeople
    {
       public static string Applicationname { get; } = "WorkWithPeople";

.....

添加包含Google项目名称的静态属性,此静态属性为只读。

将封闭字段和枚举添加到类:

        private enum             PersonStatus
        {
            Active,
            Passive
        };
        private string           _groupsresourcename;
        private List<OneContact> _list = new List<OneContact>();
        private UserCredential   _credential;
        private PeopleService    _pservice;
        private GmailService     _gservice;

枚举用于将联系人标记为“主动”(接收电子邮件)和“被动”(不接收电子邮件)。其他封闭字段:

让我们编写主要工作函数的代码:

        publicint WorkWithGoogle(string credentialfile, 
                                   string user, 
                                   string filedatastore, 
                                   string groupname,
                                   string subject,
                                   string body,
                                   bool   isHtml,
                                   List<string> attach = null)
        {
          ...

其参数是:

返回值 - 已发送电子邮件的数量。开始编写函数代码:

            if (!File.Exists(credentialfile))
                throw (new FileNotFoundException("Not found: " + credentialfile));
            using (var stream = new FileStream(credentialfile, FileMode.Open, FileAccess.Read))
            {
                if (_credential == null) {
                    _credential = GoogleWebAuthorizationBroker.AuthorizeAsync(
                        GoogleClientSecrets.Load(stream).Secrets,
                        new[]
                        {
                            GmailService.Scope.GmailSend,
                            PeopleService.Scope.ContactsReadonly
                        },
                        user,
                        CancellationToken.None,
                        new FileDataStore(filedatastore)).Result;
                        CreateServicies();
                }
                else if (_credential.Token.IsExpired(Google.Apis.Util.SystemClock.Default)) {
                    bool refreshResult = _credential.RefreshTokenAsync(CancellationToken.None).Result;
                    _list.Clear();
                    if (!refreshResult) return 0;   
                    CreateServicies();
                }
                
            }// using (var stream = new FileStream(credentialfile, FileMode.Open, FileAccess.Read))

请注意定义应请求服务的访问权限的字符串数组:

另外,注意调用 GoogleWebAuthorizationBroker.AuthorizeAsync. 它的名称表明调用是异步执行的。

请注意,如果以前收到的令牌已过期,代码将更新它并从以前形成的 _list 中删除所有对象。

辅助的 CreateServicies() 函数创建并初始化必要的对象:

        private void         CreateServicies()
        {
            _pservice = new PeopleService(new BaseClientService.Initializer()
            {
                HttpClientInitializer = _credential,
                ApplicationName = Applicationname
            });
            _gservice = new GmailService(new BaseClientService.Initializer()
            {
                HttpClientInitializer = _credential,
                ApplicationName = Applicationname
            });
        }

如我们所见,在执行上面显示的代码段后,我们可以访问必要的服务:

 - 使用JSON数据文件,我们首先请求“powers”,并将它们保存在_credential字段中。然后我们调用将“power”和项目名称字段传递给它们的服务构造函数作为初始化列表。

现在是获取为邮寄活动选择的组的联系人列表的时间:

            try {
                  if (_list.Count == 0)
                    GetPeople(_pservice, null, groupname);                     
            }
            catch (Exception ex) {
                ex.Data.Add("call GetPeople: ", ex.Message);
                throw;
            }
#if DEBUG
            int i = 1;
            foreach (var nm in _list) {
                Console.WriteLine("{0} {1} {2}", i++, nm.Name, nm.Email);
            }
#endif
            if (_list.Count == 0) {
                Console.WriteLine("Sorry, List is empty...");
                return 0;
            }

GetPeople(…)函数(稍后描述)是要填充存储联系人的 _list。此函数用作异常源,因此其块包装在try代码块中。在已连接的程序集中未检测到异常类型,因此catch块以最一般的形式写入。换句话说,我们不必在这里包括所有可能发生的事件,这样就不会丢失用于调试的有价值的数据。因此,向异常添加您认为必要的数据并重新激活它。

请记住,只有当它为空时(即当它接收到新令牌或更新旧令牌时),才会更新_ list

下一个块仅对调试版本执行. 整个形成的列表只显示在控制台中。

最后一块很明显,如果名单仍然空白,则进一步的工作没有点,并被相应的信息所阻止。

该函数以形成一个传出电子邮件并进行邮件活动而结束:

            using (MailMessage mail = new MailMessage
            {
                Subject = subject,
                Body = body,
                IsBodyHtml = isHtml
            })  // MailMessage mail = new MailMessage
            {
                if (attach != null)
                {
                    foreach (var path in attach)
                        mail.Attachments.Add(new Attachment(path));
                } //  if (attach != null)

                foreach (var nm in _list)
                    mail.To.Add(new MailAddress(nm.Email, nm.Name));
                try
                {
                    SendOneEmail(_gservice, mail);
                }
                catch (Exception ex)
                {
                    ex.Data.Add("call SendOneEmail: ", ex.Message);
                    throw;
                }
            }// using (MailMessage mail = new MailMessage

此处创建了MailMessage库类的实例。接下来是它随后的初始化和填充字段。如果有附件,则添加附件列表。最后,形成前一阶段获得的邮件列表。

邮寄由SendOneEmail(…)稍后描述的函数执行。就像GetPeople(…)函数一样,它也可能成为异常源。因此,它的调用也被包装在try代码中,而在catch中的处理也是类似的。

此时,WorkWithGoogle(…) 主函数的工作被认为是完成的,它返回_list.Count值,假设电子邮件消息从列表发送到每个联系人。

填写联系人列表

获取访问权限后,准备好填写_list,这是通过以下函数完成的:

        private void         GetPeople(PeopleService service, string pageToken, string groupName)
        {
           ...

其参数是:

第一次使用 pageToken = NULL 调用函数,如果对 Google 的请求随后返回值不同于NULL的令牌,则递归调用该函数。

            if (string.IsNullOrEmpty(_groupsresourcename))
            {
                ContactGroupsResource groupsResource = new ContactGroupsResource(service);
                ContactGroupsResource.ListRequest listRequest = groupsResource.List();
                ListContactGroupsResponse response = listRequest.Execute();
                _groupsresourcename = (from gr in response.ContactGroups
                                       where string.Equals(groupName.ToUpperInvariant(), gr.FormattedName.ToUpperInvariant())
                                       select gr.ResourceName).Single();
                if (string.IsNullOrEmpty(_groupsresourcename))
                    throw (new MissingFieldException($"Can't find GroupName: {groupName}"));
            }// if (string.IsNullOrEmpty(_groupsresourcename))

我们需要通过组名找到一个资源名,为此,请求所有资源的列表,并在简单的 lambda 表达式中找到所需的资源。请注意,应该只有一个具有所需名称的资源,如果在工作期间找不到资源,则启用异常。

Google.Apis.PeopleService.v1.PeopleResource.ConnectionsResource.ListRequest peopleRequest =
                new Google.Apis.PeopleService.v1.PeopleResource.ConnectionsResource.ListRequest(service, "people/me")
                {
                    PersonFields = "names,emailAddresses,memberships,biographies"
                };
            if (pageToken != null) {
                peopleRequest.PageToken = pageToken;
            }

让我们构造对谷歌的请求,以获得必要的列表,为此,请指定我们感兴趣的谷歌联系人数据中的字段:

最后,提出请求:

            var request = peopleRequest.Execute();
            var list1 = from person in request.Connections
                     where person.Biographies != null
                     from mem in person.Memberships
                     where string.Equals(_groupsresourcename, mem.ContactGroupMembership.ContactGroupResourceName) &&
                           PersonActive(person.Biographies.FirstOrDefault()?.Value) == PersonStatus.Active
                     let name = person.Names.First().DisplayName
                     orderby name
                     let email = person.EmailAddresses?.FirstOrDefault(p => p.Value.IsValidEmail())?.Value
                     where !string.IsNullOrEmpty(email)
                     select new OneContact(name, email);
            _list.AddRange(list1);
            if (request.NextPageToken != null) {
                GetPeople(service, request.NextPageToken, groupName);
            }
        }//void GetPeople(PeopleService service, string pageToken, string groupName)

发出请求并对 lambda 表达式中的必要数据进行排序,它看起来相当笨重,但实际上相当简单。一个联系人应该有一个非零的个人简历,在正确的组中,是一个活跃的联系人,并有一个正确的地址。让我们在这里展示通过“biographies”字段内容定义单个联系人的“active/passive”状态的功能:

        private PersonStatus PersonActive(string value)
        {
            try {
                switch (Int32.Parse(value))
                {
                    case 1:
                        return PersonStatus.Active;
                    default:
                        return PersonStatus.Passive;
                }
            }
            catch (FormatException)   { return PersonStatus.Passive; }
            catch (OverflowException) { return PersonStatus.Passive; }
        }//PersonStatus PersonActive(string value)

这是项目中唯一一个不寻求重新启用异常(尝试在现场处理其中一些异常)的函数。

我们快完成了!将获得的列表添加到_list中,如果没有读取所有联系人,则使用新的标记值递归调用函数。

发送电子邮件

这由以下辅助函数执行:

        private void SendOneEmail(GmailService service, MailMessage mail)
        {
            MimeKit.MimeMessage mimeMessage = MimeKit.MimeMessage.CreateFromMailMessage(mail);
            var encodedText = Base64UrlEncode(mimeMessage.ToString());
            var message = new Message { Raw = encodedText };

            var request = service.Users.Messages.Send(message, "me").Execute();
        }//  bool SendOneEmail(GmailService service, MailMessage mail)

它的调用如上所述,这个简单函数的目的是为发送和执行邮件活动准备电子邮件,此外,该函数还具有所有“繁重”的准备工作。不幸的是,Google不接受MailMessage类形式的数据,因此,以可接受的形式准备数据并对其进行编码。MimeKit程序集包括执行编码的工具。但是,我相信使用一个我们可以使用的简单函数要容易得多,我不会在这里展示它,因为它很简单。请注意在Service.Users.Messages.Send调用中的string类型的userId,它等于“me”的特殊值,允许Google访问您的帐户以获取发送者数据。

这就结束了对ContactsPeople类的分析,其余的函数是辅助的,因此没有必要停留在它们上面。

终端连接器

唯一剩下的问题是将(未完成的)组件连接到终端。乍一看,任务很简单,定义几个静态方法,编译项目并将其复制到终端的库文件夹中。从MQL代码调用程序集的静态方法。但我们究竟应该复制什么呢?有一个dll库形式的程序集。在我们的工作中,NuGet还下载了十几个程序集。有一个JSON文件存储用于访问Google的数据。让我们尝试将整个集合复制到“Libraries”文件夹。创建一个基本的MQL脚本(这里没有附加代码的意义),然后尝试从程序集调用静态方法。异常!没有找到 Google.Apis.dll,这是一个非常令人不快的惊喜,这意味着CLR无法找到所需的程序集,尽管它与我们的主程序集位于同一文件夹中。为什么会这样?不值得详细研究这里的情况。所有对细节感兴趣的人都可以在 Richter 的名著中找到它们(在关于寻找私有程序库的章节中)。

已经有许多与 MetaTrader 一起工作的功能完备的.NET应用程序的例子,在那里也发生了这样的问题。它们是怎么解决的呢?在这里通过在.NET应用程序和MQL程序之间创建通道解决了这个问题,而这里使用了一种基于事件的模式。我可以建议使用类似的方法,使用命令行将所需数据从MQL程序传递到.NET应用程序。

但值得考虑的是更“优雅”,简单和普遍的解决方案。我的意思是使用 AppDomain.AssemblyResolve事件管理程序集下载。当执行要求无法按名称绑定程序集时,会发生此事件。在这种情况下,事件处理程序可以从另一个文件夹(拥有处理程序知道的地址)加载和返回程序集。因此,在这里提出一个相当漂亮的解决方案:

  1. 在 “Libraries” 文件夹中创建一个具有不同名称的文件夹(在我的例子中,它是“WorkWithPeople”),
  2. 将其方法导入到带有MQL的文件中的程序集复制到 “Libraries”文件夹。
  3. 所有其他项目程序集,包括包含访问Google服务数据的JSON文件,都被复制到“WorkWithPeople”文件夹中。
  4. 让libraries文件夹中的主程序集知道它应该在哪里查找其他程序集 - “WorkWithPeople”文件夹的完整路径。

这样,我们就得到了一个可行的解决方案,而不会把“Libraries”文件夹弄乱。剩下的就是在代码中实现决策了。

控制类

让我们创建一个静态类

    public static class Run
    {

        static Run() {
            AppDomain.CurrentDomain.AssemblyResolve += ResolveAssembly;
        }// Run()

并向其中添加事件处理程序,以便它尽快出现在处理程序链中。让我们定义处理程序本身:

        static Assembly ResolveAssembly(object sender, ResolveEventArgs args) {
            String dllName = new AssemblyName(args.Name).Name + ".dll";
            return Assembly.LoadFile(Path.Combine(_path, dllName) );
        }// static Assembly ResolveAssembly(object sender, ResolveEventArgs args)

现在,每当检测到程序集时,都会调用此处理程序。其目标是下载并返回程序集,该程序集结合了_path变量和计算名称之间的路径。现在,只有处理程序找不到程序集时才会出现异常。

初始化函数如下:

WorkwithPeoplepublic static void Initialize(string Path, string GoogleGroup, string AdminEmail, string Storage)
        {
            if (string.IsNullOrEmpty(Path) ||
                string.IsNullOrEmpty(GoogleGroup) ||
                string.IsNullOrEmpty(AdminEmail) ||
                string.IsNullOrEmpty(Storage)) throw (new MissingFieldException("Initialize: bad parameters"));
            _group = GoogleGroup;
            _user = AdminEmail;
            _storage = Storage;
            _path = Path;
        }//  Initialize(string Path, string GoogleGroup, string AdminEmail, string Storage)

在尝试发送电子邮件之前,应首先调用此函数,其参数是:

所有描述的参数不应为空字符串,否则将激活异常。

为包含的文件创建一个列表和一个简单的添加函数:

public static void AddAttachment (string attach) { _attachList.Add(attach);}

该函数没有错误检查工具,因为它处理的是在 MetaTrader 环境中初步创建的屏幕截图和其他文件。假设这是由终端中工作的控制工具完成的。

让我们马上创建一个邮寄对象

static ContactsPeople _cContactsPeople = new ContactsPeople();

并通过调用函数来执行:

public static int DoWork(string subject, string body, bool isHtml = false) {
            if (string.IsNullOrEmpty(body))
                throw (new MissingFieldException("Email body null or empty"));
            int res = 0;
            if (_attachList.Count > 0) {
                res = _cContactsPeople.WorkWithGoogle(Path.Combine(_path, "WorkWithPeople_gmail.json"),
                    _user,
                    _storage,
                    _group,
                    subject,
                    body,
                    isHtml,
                    _attachList);
                _attachList.Clear();
            } else {
                res = _cContactsPeople.WorkWithGoogle(Path.Combine(_path, "WorkWithPeople_gmail.json"),
                    _user,
                    _storage,
                    _group,
                    subject,
                    body,
                    isHtml);
            }// if (_attachList.Count > 0) ... else ...
            return res;
        }// static int DoWork(string subject, string body, bool isHtml = false)

输入参数如下:

有两个选项可以调用_cContactsPeople.WorkWithGoogle,具体取决于电子邮件功能附件。调用的第一个参数特别有趣:

Path.Combine(_path, "WorkWithPeople_gmail.json")

这是包含用于访问Google服务的数据的文件的完整路径。

DoWork(…)函数返回已发送电子邮件的数量。

除用于访问Google的数据文件外,VS++2017的整个项目位于所附的google.zip档案中。

MetaTrader 方面的准备工作

程序集代码已就绪,让我们继续到终端,在那里创建一个简单的脚本。可以这样编写(跳过开头部分代码):

#import "WorkWithPeople.dll"


void OnStart()
  {
   string scr = "scr.gif";
   string fl = TerminalInfoString(TERMINAL_DATA_PATH) + "\\MQL5\\Files\\";
   ChartScreenShot(0, scr, 800, 600);  
   Run::Initialize("e:\\Forex\\RoboForex MT5 Demo\\MQL5\\Libraries\\WorkWithPeople\\" ,"Forex" ,"ХХХХХХ@gmail.com" ,"WorkWithPeople" );
   Run::AddAttachment(fl + scr);
   int res = Run::DoWork("some subj" ,
                         "Very big body" ,
                          false );
   Print("result: ", res);   
  }

代码非常清楚。导入程序集。我们要做的第一件事是初始化它,添加先前制作的屏幕截图并执行邮件发送。完整的代码可以在附加文件 google_test1.mq5中找到。

另一个例子是在M5上工作的指标,每次检测到新烛形时都会发送带有屏幕截图的电子邮件:

#import "WorkWithPeople.dll"

input string scr="scr.gif";

string fp;

int OnInit()
  {
   fp=TerminalInfoString(TERMINAL_DATA_PATH)+"\\MQL5\\Files\\";
   Run::Initialize("e:\\Forex\\RoboForex MT5 Demo\\MQL5\\Libraries\\WorkWithPeople\\","Forex","0ndrei1960@gmail.com","WorkWithPeople");

   return(INIT_SUCCEEDED);
  }

int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
  {
   if(IsNewCandle()) 
     {
      ChartScreenShot(0,scr,800,600);
      Run::AddAttachment(fp+scr);
      string body="Time: "+TimeToString(TimeLocal());
      int res=Run::DoWork("some subj",body,false);
      Print(body);
     }
   return(rates_total);  
  }

指标的完整代码可以在所附 google_test2.mq5 文件中找到,它非常简单,因此不需要进一步的讨论。

结论

让我们看看结果,我们分析了使用谷歌联系人与合作伙伴进行交互,以及将程序集与终端集成的方法,使用户可以避免将文件夹与不必要的文件混淆。汇编代码的效率也值得一提,我们对这一问题的关注不够,但可以提供一系列活动来解决这一问题:

这并不意味着您应该使用所有这些方法,但它们的使用可能会提高性能,并允许将生成的程序集作为单独流程的一部分与MetaTrader一起单独应用。

总之,让我们回到使用MQL工具解决此任务的问题,有可能吗?根据谷歌文档,答案是肯定的,使用GET/POSTt请求可以获得相同的结果,并提供适当的示例。因此,可以使用常规的WebRequest。这种方法的可行性仍然是一个争论的问题,由于请求的数量非常多,因此很难编写、调试和维护这样的代码。

文章中使用的程序

 # 名称
类型
 描述
1 google_test1.mq5
脚本
制作屏幕截图并发送到多个地址的脚本。
2
google_test1.mq5 指标
在每个新烛形上发送电子邮件的示例指标
3 google.zip 档案 程序集和测试控制台应用程序项目。